From 0cc1779faf714adba84a096c6935a605c5aef58a Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 15 Aug 2026 08:37:38 +0530 Subject: [PATCH 1/6] fix(cli): send forum roots and replies as forum kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `messages send` published kind:9 whenever `--kind` was omitted. Desktop's forum surface lists kind:45001 topics and their kind:45003 comments, so a default send into a forum channel was accepted by the relay, returned an event id, and then never appeared where people read that channel. The event stayed reachable by deep link or notification, which is what makes the failure hard to spot. Resolve the channel's type from its kind:39000 discovery event and pick the kind that channel's canonical surface shows. An explicit `--kind` is still honoured unchanged, and `--broadcast` stays on kind:9 — it is a stream-only concept, so a flag should not silently redirect the event kind. Signed-off-by: Taksh --- crates/buzz-cli/src/commands/messages.rs | 75 +++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 40a9ae80b56..9cd3a1bcd28 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -561,6 +561,64 @@ fn match_profiles_by_name(events: &[serde_json::Value], name: &str) -> Vec<(Stri matches } +/// Channel type recorded on the relay's kind:39000 discovery event (`t` tag). +fn channel_type_from_metadata(event: &serde_json::Value) -> Option<&str> { + event + .get("tags")? + .as_array()? + .iter() + .find_map(|tag| { + let tag = tag.as_array()?; + (tag.first()?.as_str()? == "t").then(|| tag.get(1)?.as_str()) + }) + .flatten() +} + +/// Kind to publish when `--kind` was not given. +/// +/// Desktop's forum surface lists kind:45001 topics and kind:45003 comments; a +/// kind:9 event in a forum channel is accepted by the relay and then never +/// appears in the topic list, so the send looks successful while the content +/// is invisible where people read it. Match the channel's canonical surface +/// instead of always defaulting to the stream kind. +/// +/// `--broadcast` stays on kind:9: it is a stream-only concept, and an explicit +/// flag should not be silently redirected to a different event kind. +fn default_message_kind(channel_type: Option<&str>, is_reply: bool, broadcast: bool) -> u16 { + match channel_type { + Some("forum") if !broadcast => { + if is_reply { + 45003 + } else { + 45001 + } + } + _ => 9, + } +} + +/// Read a channel's type from its kind:39000 discovery event. +/// +/// Returns `None` when the relay has no metadata for the channel or the event +/// carries no `t` tag — the caller then keeps the stream default rather than +/// failing a send over a missing discovery event. +async fn resolve_channel_type( + client: &BuzzClient, + channel_id: &str, +) -> Result, CliError> { + let filter = serde_json::json!({ + "kinds": [39000], + "#d": [channel_id], + "limit": 1, + }); + let raw = client.query(&filter).await?; + let events: Vec = serde_json::from_str(&raw).unwrap_or_default(); + Ok(events + .first() + .and_then(channel_type_from_metadata) + .map(str::to_owned)) +} + pub struct SendMessageParams { pub channel_id: String, pub content: String, @@ -643,7 +701,22 @@ pub async fn cmd_send_message( let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect(); - let builder = match p.kind { + // Without an explicit --kind, follow the channel's canonical surface: a + // forum shows kind:45001 topics and kind:45003 comments, so a kind:9 event + // published there is accepted and then never listed. + let kind = match p.kind { + Some(k) => Some(k), + None => { + let channel_type = resolve_channel_type(client, &p.channel_id).await?; + Some(default_message_kind( + channel_type.as_deref(), + thread_ref.is_some(), + p.broadcast, + )) + } + }; + + let builder = match kind { Some(45001) => { buzz_sdk::build_forum_post(channel_uuid, &final_content, &mention_refs, &media_tags) .map_err(|e| CliError::Other(format!("build_forum_post failed: {e}")))? From 5794469dc73dd68f2ddbff9e2d895aa25d885c8e Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 15 Aug 2026 08:38:22 +0530 Subject: [PATCH 2/6] test(cli): cover channel-aware kind selection Pins the `t`-tag read, the forum root/reply split, the stream and DM cases, and that --broadcast is left on kind:9. Signed-off-by: Taksh --- crates/buzz-cli/src/commands/messages.rs | 50 ++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 9cd3a1bcd28..fc6d55d1ebe 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -1446,3 +1446,53 @@ mod tests { assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1); } } + +#[cfg(test)] +mod default_kind_tests { + use super::{channel_type_from_metadata, default_message_kind}; + + fn metadata(tags: serde_json::Value) -> serde_json::Value { + serde_json::json!({ "kind": 39000, "tags": tags }) + } + + #[test] + fn reads_the_channel_type_from_the_t_tag() { + let event = metadata(serde_json::json!([ + ["d", "2cf6cfd0-b917-4ea0-b2d8-a29dea949b77"], + ["closed"], + ["t", "forum"], + ])); + assert_eq!(channel_type_from_metadata(&event), Some("forum")); + } + + #[test] + fn missing_t_tag_reads_as_unknown() { + let event = metadata(serde_json::json!([["d", "x"], ["closed"]])); + assert_eq!(channel_type_from_metadata(&event), None); + } + + #[test] + fn forum_root_defaults_to_a_topic() { + assert_eq!(default_message_kind(Some("forum"), false, false), 45001); + } + + #[test] + fn forum_reply_defaults_to_a_comment() { + assert_eq!(default_message_kind(Some("forum"), true, false), 45003); + } + + #[test] + fn stream_and_dm_keep_kind_nine() { + for channel_type in [Some("stream"), Some("dm"), Some("workflow"), None] { + assert_eq!(default_message_kind(channel_type, false, false), 9); + assert_eq!(default_message_kind(channel_type, true, false), 9); + } + } + + #[test] + fn broadcast_stays_on_the_stream_kind() { + // --broadcast is stream-only; redirecting it to a forum kind would + // change what the flag does rather than where the message lands. + assert_eq!(default_message_kind(Some("forum"), false, true), 9); + } +} From 8967f1208ca6edf18486e7c74c40af6bd2fe2610 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 15 Aug 2026 08:39:23 +0530 Subject: [PATCH 3/6] feat(cli): spell out what the default kind resolves to "channel default" was accurate but unhelpful; name the kinds so a caller can tell whether they need --kind at all. Signed-off-by: Taksh --- crates/buzz-cli/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 2b041da57b5..75d7f41a23c 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -380,7 +380,8 @@ pub enum MessagesCmd { /// Message text — supports @mentions and markdown. Use '-' to read from stdin. #[arg(long)] content: String, - /// Nostr event kind (default: channel default) + /// Nostr event kind. Defaults to the channel's canonical kind: + /// 45001 for a forum topic, 45003 for a forum reply, 9 otherwise. #[arg(long)] kind: Option, /// Event ID to reply to (creates a thread) From 1aebef7a7ed2834b8389591f27cf5e2b345b3f4e Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 16 Aug 2026 21:11:15 +0530 Subject: [PATCH 4/6] fix(cli): reject --broadcast where it cannot be honoured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review points out two invisible-success paths the first commit left open, and both come back to --broadcast being a stream-only concept: - An implicit forum send with --broadcast fell back to kind 9. That event is accepted by the relay and never listed on the forum surface — the exact failure this PR exists to fix, reachable by adding a flag. - An explicit --kind 45001/45003 with --broadcast dropped the flag silently: build_forum_post and build_forum_comment take no broadcast argument. Selection is now one tested contract, resolve_message_kind, which rejects both rather than half-applying them. --kind 9 --broadcast stays as the explicit opt-out, in a forum channel too: the caller named the kind. The unsupported --kind error moves into the same function, so every kind decision has one home. Signed-off-by: Taksh --- crates/buzz-cli/src/commands/messages.rs | 170 +++++++++++++++++------ 1 file changed, 127 insertions(+), 43 deletions(-) diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index fc6d55d1ebe..a0690a940e1 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -574,26 +574,48 @@ fn channel_type_from_metadata(event: &serde_json::Value) -> Option<&str> { .flatten() } -/// Kind to publish when `--kind` was not given. +/// The single decision about which kind a `messages send` publishes. /// /// Desktop's forum surface lists kind:45001 topics and kind:45003 comments; a /// kind:9 event in a forum channel is accepted by the relay and then never /// appears in the topic list, so the send looks successful while the content -/// is invisible where people read it. Match the channel's canonical surface -/// instead of always defaulting to the stream kind. +/// is invisible where people read it. Matching the channel's canonical surface +/// is what removes that failure — which is also why `--broadcast` cannot +/// quietly opt back into it. /// -/// `--broadcast` stays on kind:9: it is a stream-only concept, and an explicit -/// flag should not be silently redirected to a different event kind. -fn default_message_kind(channel_type: Option<&str>, is_reply: bool, broadcast: bool) -> u16 { - match channel_type { - Some("forum") if !broadcast => { - if is_reply { - 45003 - } else { - 45001 - } - } - _ => 9, +/// `--broadcast` is stream-only. Two combinations are rejected rather than +/// half-applied: +/// +/// * an implicit forum send with `--broadcast` — publishing kind:9 there is +/// the exact invisible-success this command is fixing, so it fails instead; +/// * an explicit forum kind with `--broadcast` — `build_forum_post` and +/// `build_forum_comment` take no broadcast argument, so the flag was being +/// silently dropped. +/// +/// `--kind 9 --broadcast` stays available as the explicit opt-out: the caller +/// has said which kind they want, and kind:9 is what broadcast means. +fn resolve_message_kind( + explicit: Option, + channel_type: Option<&str>, + is_reply: bool, + broadcast: bool, +) -> Result { + let is_forum = channel_type == Some("forum"); + match explicit { + Some(kind @ (45001 | 45003)) if broadcast => Err(CliError::Usage(format!( + "--broadcast is stream-only and does not apply to kind {kind}; drop --broadcast, or use --kind 9 --broadcast to post a broadcast stream message" + ))), + Some(kind @ (9 | 45001 | 45003)) => Ok(kind), + Some(kind) => Err(CliError::Usage(format!( + "--kind {kind} is not supported (use 9, 45001, or 45003)" + ))), + None if is_forum && broadcast => Err(CliError::Usage( + "--broadcast is stream-only, and this is a forum channel: a kind 9 event there is accepted by the relay but never listed on the forum surface. Drop --broadcast to post a forum topic or comment, or use --kind 9 --broadcast if the invisible stream message is really what you want." + .into(), + )), + None if is_forum && is_reply => Ok(45003), + None if is_forum => Ok(45001), + None => Ok(9), } } @@ -701,27 +723,23 @@ pub async fn cmd_send_message( let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect(); - // Without an explicit --kind, follow the channel's canonical surface: a - // forum shows kind:45001 topics and kind:45003 comments, so a kind:9 event - // published there is accepted and then never listed. - let kind = match p.kind { - Some(k) => Some(k), - None => { - let channel_type = resolve_channel_type(client, &p.channel_id).await?; - Some(default_message_kind( - channel_type.as_deref(), - thread_ref.is_some(), - p.broadcast, - )) - } + let channel_type = match p.kind { + Some(_) => None, + None => resolve_channel_type(client, &p.channel_id).await?, }; + let kind = resolve_message_kind( + p.kind, + channel_type.as_deref(), + thread_ref.is_some(), + p.broadcast, + )?; let builder = match kind { - Some(45001) => { + 45001 => { buzz_sdk::build_forum_post(channel_uuid, &final_content, &mention_refs, &media_tags) .map_err(|e| CliError::Other(format!("build_forum_post failed: {e}")))? } - Some(45003) => { + 45003 => { let tr = thread_ref.as_ref().ok_or_else(|| { CliError::Usage("--reply-to is required for forum comments (kind 45003)".into()) })?; @@ -734,7 +752,7 @@ pub async fn cmd_send_message( ) .map_err(|e| CliError::Other(format!("build_forum_comment failed: {e}")))? } - None | Some(9) => buzz_sdk::build_message( + 9 => buzz_sdk::build_message( channel_uuid, &final_content, thread_ref.as_ref(), @@ -743,9 +761,12 @@ pub async fn cmd_send_message( &media_tags, ) .map_err(|e| CliError::Other(format!("build_message failed: {e}")))?, - Some(k) => { - return Err(CliError::Usage(format!( - "--kind {k} is not supported (use 9, 45001, or 45003)" + // `resolve_message_kind` is the only source of `kind` and returns + // nothing else; an unsupported `--kind` is rejected there, before any + // upload. + other => { + return Err(CliError::Other(format!( + "internal: unhandled message kind {other}" ))) } }; @@ -1449,7 +1470,12 @@ mod tests { #[cfg(test)] mod default_kind_tests { - use super::{channel_type_from_metadata, default_message_kind}; + use super::{channel_type_from_metadata, resolve_message_kind}; + + /// `resolve_message_kind` with no explicit `--kind` and no `--broadcast`. + fn implicit(channel_type: Option<&str>, is_reply: bool) -> u16 { + resolve_message_kind(None, channel_type, is_reply, false).expect("must resolve") + } fn metadata(tags: serde_json::Value) -> serde_json::Value { serde_json::json!({ "kind": 39000, "tags": tags }) @@ -1473,26 +1499,84 @@ mod default_kind_tests { #[test] fn forum_root_defaults_to_a_topic() { - assert_eq!(default_message_kind(Some("forum"), false, false), 45001); + assert_eq!(implicit(Some("forum"), false), 45001); } #[test] fn forum_reply_defaults_to_a_comment() { - assert_eq!(default_message_kind(Some("forum"), true, false), 45003); + assert_eq!(implicit(Some("forum"), true), 45003); } #[test] fn stream_and_dm_keep_kind_nine() { for channel_type in [Some("stream"), Some("dm"), Some("workflow"), None] { - assert_eq!(default_message_kind(channel_type, false, false), 9); - assert_eq!(default_message_kind(channel_type, true, false), 9); + assert_eq!(implicit(channel_type, false), 9); + assert_eq!(implicit(channel_type, true), 9); } } #[test] - fn broadcast_stays_on_the_stream_kind() { - // --broadcast is stream-only; redirecting it to a forum kind would - // change what the flag does rather than where the message lands. - assert_eq!(default_message_kind(Some("forum"), false, true), 9); + fn an_explicit_kind_wins_over_the_channel_type() { + assert_eq!( + resolve_message_kind(Some(9), Some("forum"), false, false).unwrap(), + 9 + ); + assert_eq!( + resolve_message_kind(Some(45001), Some("stream"), false, false).unwrap(), + 45001 + ); + } + + #[test] + fn an_implicit_forum_broadcast_is_rejected() { + // Publishing kind 9 into a forum is the invisible success this command + // is fixing, so --broadcast there must fail rather than fall back. + let err = resolve_message_kind(None, Some("forum"), false, true).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("stream-only"), "unexpected error: {msg}"); + assert!( + msg.contains("--kind 9 --broadcast"), + "error must name the opt-out: {msg}" + ); + } + + #[test] + fn an_explicit_forum_kind_with_broadcast_is_rejected() { + // build_forum_post / build_forum_comment take no broadcast argument, + // so honouring both is impossible — the flag used to be dropped. + for kind in [45001u16, 45003] { + let err = resolve_message_kind(Some(kind), Some("forum"), true, true).unwrap_err(); + assert!( + err.to_string().contains("stream-only"), + "kind {kind} must reject --broadcast: {err}" + ); + } + } + + #[test] + fn an_explicit_stream_broadcast_is_the_opt_out() { + // Even in a forum channel: the caller named the kind. + assert_eq!( + resolve_message_kind(Some(9), Some("forum"), false, true).unwrap(), + 9 + ); + } + + #[test] + fn broadcast_does_not_change_a_stream_channel() { + assert_eq!( + resolve_message_kind(None, Some("stream"), false, true).unwrap(), + 9 + ); + assert_eq!(resolve_message_kind(None, None, true, true).unwrap(), 9); + } + + #[test] + fn an_unsupported_kind_is_rejected_before_anything_is_uploaded() { + let err = resolve_message_kind(Some(1), None, false, false).unwrap_err(); + assert!( + err.to_string().contains("not supported"), + "unexpected error: {err}" + ); } } From abaf8f9d80ef610556589f5de853e055e1ad8544 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 16 Aug 2026 21:11:15 +0530 Subject: [PATCH 5/6] docs(cli): say that --broadcast is stream-only The review asks that CLI help identify broadcast as stream-only, now that a forum invocation is an error rather than a silent kind 9. Signed-off-by: Taksh --- crates/buzz-cli/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 75d7f41a23c..535adf2b473 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -387,7 +387,8 @@ pub enum MessagesCmd { /// Event ID to reply to (creates a thread) #[arg(long)] reply_to: Option, - /// Also publish to the Nostr network + /// Also publish to the Nostr network. Stream-only (kind 9): rejected + /// in a forum channel and with --kind 45001/45003. #[arg(long, default_value_t = false)] broadcast: bool, /// Attach file(s) — uploads and includes as imeta tags From da2a99da91c2f7eddd333e8b4912f92bfcf61b40 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 16 Aug 2026 21:11:21 +0530 Subject: [PATCH 6/6] fix(cli): settle the message kind before uploading attachments The review asks that selection run before attachment upload, so an invalid broadcast combination cannot leave orphaned media on the relay. Every rejection in resolve_message_kind is a usage error the caller retries, and the upload loop ran first, so the files were already stored with no event referencing them. The reply flag is read from p.reply_to.is_some() rather than thread_ref.is_some(); thread_ref is Some exactly when reply_to is, and it is resolved by a relay round-trip that no longer needs to happen first. Signed-off-by: Taksh --- crates/buzz-cli/src/commands/messages.rs | 25 +++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index a0690a940e1..33568fe76cf 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -690,6 +690,20 @@ pub async fn cmd_send_message( )); } + // Settle the kind before anything is uploaded. Every rejection above is a + // usage error the caller can fix and retry; deciding after the upload loop + // would leave the attachments on the relay with no event referencing them. + let channel_type = match p.kind { + Some(_) => None, + None => resolve_channel_type(client, &p.channel_id).await?, + }; + let kind = resolve_message_kind( + p.kind, + channel_type.as_deref(), + p.reply_to.is_some(), + p.broadcast, + )?; + // Upload files and build imeta tags let mut media_tags: Vec> = Vec::new(); let mut media_content = String::new(); @@ -723,17 +737,6 @@ pub async fn cmd_send_message( let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect(); - let channel_type = match p.kind { - Some(_) => None, - None => resolve_channel_type(client, &p.channel_id).await?, - }; - let kind = resolve_message_kind( - p.kind, - channel_type.as_deref(), - thread_ref.is_some(), - p.broadcast, - )?; - let builder = match kind { 45001 => { buzz_sdk::build_forum_post(channel_uuid, &final_content, &mention_refs, &media_tags)