diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 40a9ae80b56..33568fe76cf 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -561,6 +561,86 @@ 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() +} + +/// 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. Matching the channel's canonical surface +/// is what removes that failure — which is also why `--broadcast` cannot +/// quietly opt back into it. +/// +/// `--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), + } +} + +/// 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, @@ -610,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(); @@ -643,12 +737,12 @@ pub async fn cmd_send_message( let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect(); - let builder = match p.kind { - Some(45001) => { + let builder = match kind { + 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()) })?; @@ -661,7 +755,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(), @@ -670,9 +764,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}" ))) } }; @@ -1373,3 +1470,116 @@ mod tests { assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1); } } + +#[cfg(test)] +mod default_kind_tests { + 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 }) + } + + #[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!(implicit(Some("forum"), false), 45001); + } + + #[test] + fn forum_reply_defaults_to_a_comment() { + 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!(implicit(channel_type, false), 9); + assert_eq!(implicit(channel_type, true), 9); + } + } + + #[test] + 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}" + ); + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 2b041da57b5..535adf2b473 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -380,13 +380,15 @@ 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) #[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