Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 217 additions & 7 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u16>,
channel_type: Option<&str>,
is_reply: bool,
broadcast: bool,
) -> Result<u16, CliError> {
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<Option<String>, CliError> {
let filter = serde_json::json!({
"kinds": [39000],
"#d": [channel_id],
"limit": 1,
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = 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,
Expand Down Expand Up @@ -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<String>> = Vec::new();
let mut media_content = String::new();
Expand Down Expand Up @@ -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())
})?;
Expand All @@ -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(),
Expand All @@ -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}"
)))
}
};
Expand Down Expand Up @@ -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}"
);
}
}
6 changes: 4 additions & 2 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u16>,
/// Event ID to reply to (creates a thread)
#[arg(long)]
reply_to: Option<String>,
/// 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
Expand Down