feat(cli): send a thread message to the channel (#8778)

> Replaces #8769, which was opened from a fork.

## Summary

This change adds `buzz messages send --from-thread <root-event-id>`.
The flag is the CLI form of desktop "Send to channel".
It publishes a new top-level message in the channel.
The message carries the tag `["buzz:sent-from-thread", <root-id>,
<excerpt>]`.
The message has no NIP-10 reply tags.

## Design

Desktop puts the thread root id in the tag. It does not use the selected
message id.
Desktop publishes kind 9. The CLI does the same.
The CLI checks the root event before it publishes anything.
The root must exist. The root must have the same `h` tag as `--channel`.
The root must be a thread root. If the id belongs to a reply, the CLI
fails and prints the real root id.
A missing root exits with a not-found error. Other failures exit with a
usage error.
No event is published after a failed check.
The excerpt comes from the root content.
It is a Rust port of `summarizeThreadRoot` in `sentFromThread.ts`.
The port clips by code point, as desktop does. It breaks words by UTF-16
unit, as desktop does.
`--from-thread` conflicts with `--reply-to` and `--broadcast`. It works
only with kind 9.
The relay does not validate this tag. Desktop's Tauri code validates it
(`message_tags.rs`).
The CLI output follows those rules: 64 hex characters, an excerpt of at
most 64 characters, and no control characters.
The change adds no dependency and no SDK change. The CLI appends the tag
to the SDK `build_message` builder.
The README describes the flag. This PR does not change `desktop/`
(deprecated; see #8840).

## Testing

What ran:
- `cargo test -p buzz-cli`: 508 passed.
- `cargo clippy -p buzz-cli --all-targets -- -D warnings`: clean.
- `cargo fmt --all --check`: clean.
- `just file-size-check`: passed.

Test coverage:
- One test calls `cmd_send_message` against a fake relay. It covers
success, a reply id, another channel, a missing root, an invalid id, and
a non-kind-9 send. The failure cases publish nothing. The success case
publishes one event with the tag and without `e` or `broadcast` tags.
- One test checks the clap conflicts.
- Unit tests cover the excerpt boundaries (63, 64, 65 characters, the
word-break threshold, and multi-byte and emoji text).
- One test compares the Rust excerpt with 639 vectors. Desktop's own
`summarizeThreadRoot` generated the vectors. The first run found an
ordering bug (desktop replaces each pattern over the whole text in
turn). The code now does the same.

Interop evidence:
- A node script runs the real `buzz` binary against a local fake relay.
It is not a live relay.
- The script passes the published tags to desktop's
`getSentFromThreadReference` and `getSentFromThreadRootId`.
- Desktop accepts the tag, returns the same root id, and returns the
same excerpt as `summarizeThreadRoot`.
- Output: `["buzz:sent-from-thread","abab…ab","Launch plan for the doc 😀
word word word word word word word…"]` then `INTEROP OK`.

Mutation checks. Each guard was broken once. A test failed each time.
Then the guard was restored.
- Channel check removed: success test fails.
- Reply-root check removed: success test fails.
- Kind check removed: success test fails.
- `--broadcast` conflict removed: clap test fails.
- Word-break threshold `>` changed to `>=`: three excerpt tests fail.
- UTF-16 break changed to code-point break: two excerpt tests fail.
- Tag not attached: success test fails.

## Manual test steps

Use a test channel. You need `BUZZ_RELAY_URL` and `BUZZ_PRIVATE_KEY`
set. Build with `cargo build -p buzz-cli`.

1. Post a root message. Copy its event id as `ROOT`.
`buzz messages send --channel <CHANNEL> --content "Launch plan:
[doc](https://example.com)"`
2. Send it to the channel.
`buzz messages send --channel <CHANNEL> --content "Decision: ship it"
--from-thread $ROOT`
Success: the output has `"accepted":true`. A new top-level message
appears in the channel.
3. Read it back.
   `buzz --format json messages get --channel <CHANNEL> --limit 1`
Success: the tags include `["buzz:sent-from-thread","<ROOT>","Launch
plan: doc"]`. There are no `e` tags.
4. Open the channel in desktop.
   Success: the message shows the "sent from thread" line for that root.
5. Try a reply id.
`buzz messages send --channel <CHANNEL> --content x --from-thread
<REPLY_ID>`
Success: exit code 1. The error names the root id. Nothing is published.
6. Try a root from another channel, and try `--reply-to` together with
`--from-thread`.
   Success: both fail and publish nothing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: bmcanally <bmcanally@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Blake McAnally
2026-11-05 17:28:19 -06:00
committed by GitHub
parent 5c91e0d6fe
commit 303157f53d
6 changed files with 3072 additions and 5 deletions

View File

@@ -32,6 +32,7 @@ export BUZZ_RELAY_URL="https://relay.example.com"
buzz messages send --channel <uuid> --content "Hello"
buzz messages send --channel <uuid> --content "Reply" --reply-to <event-id> --broadcast
buzz messages send --channel <uuid> --content - < message.md # read body from stdin
buzz messages send --channel <uuid> --content "Decision: ship" --from-thread <root-event-id> # desktop "Send to channel"
buzz messages get --channel <uuid> --limit 20
buzz messages thread --channel <uuid> --event <event-id>
buzz messages thread --link 'buzz://message?channel=<uuid>&id=<event-id>&thread=<root-id>'

View File

@@ -4,6 +4,7 @@ use uuid::Uuid;
use crate::client::{normalize_events, normalize_write_response, BuzzClient};
use crate::commands::event_output::{format_events, thread_markers};
use crate::commands::sent_from_thread;
use crate::error::CliError;
use crate::validate::{
infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff,
@@ -579,6 +580,46 @@ pub struct SendMessageParams {
pub broadcast: bool,
pub files: Vec<String>,
pub mentions: Vec<String>,
pub from_thread: Option<String>,
}
/// Verify the thread root exists in `channel` and build the provenance tag
/// desktop's "Send to channel" attaches.
async fn resolve_sent_from_thread_tag(
client: &BuzzClient,
channel: Uuid,
root_event_id: &str,
) -> Result<nostr::Tag, CliError> {
let root_hex = parse_event_id(root_event_id)?.to_hex();
let root = fetch_event(client, &root_hex).await?;
sent_from_thread_tag_for_root(channel, &root_hex, &root)
}
fn sent_from_thread_tag_for_root(
channel: Uuid,
root_hex: &str,
root: &serde_json::Value,
) -> Result<nostr::Tag, CliError> {
if channel_id_from_event(root_hex, root)? != channel {
return Err(CliError::Usage(format!(
"--from-thread event {root_hex} is not in channel {channel}"
)));
}
if root.get("kind").and_then(|k| k.as_u64()) != Some(9) {
return Err(CliError::Usage(format!(
"--from-thread event {root_hex} is not an ordinary channel message (kind 9)"
)));
}
if let Some(real_root) = root.get("tags").and_then(find_root_from_tags) {
return Err(CliError::Usage(format!(
"--from-thread takes a thread root, but {root_hex} is a reply; use {real_root}"
)));
}
let content = root.get("content").and_then(|c| c.as_str()).unwrap_or("");
Ok(sent_from_thread::build_tag(
root_hex,
sent_from_thread::summarize_thread_root(content).as_deref(),
))
}
pub async fn cmd_send_message(
@@ -595,6 +636,15 @@ pub async fn cmd_send_message(
validate_hex64(r)?;
}
let channel_uuid = parse_uuid(&p.channel_id)?;
if p.from_thread.is_some() && !matches!(p.kind, None | Some(9)) {
return Err(CliError::Usage(
"--from-thread only applies to kind 9 messages".into(),
));
}
let sent_from_thread_tag = match p.from_thread.as_deref() {
Some(root) => Some(resolve_sent_from_thread_tag(client, channel_uuid, root).await?),
None => None,
};
let explicit_mentions = normalize_explicit_mentions(&p.mentions)?;
let stripped = strip_code_regions(&p.content);
@@ -697,7 +747,7 @@ pub async fn cmd_send_message(
} else {
Vec::new()
};
buzz_sdk::build_message(
let builder = buzz_sdk::build_message(
channel_uuid,
&final_content,
thread_ref.as_ref(),
@@ -706,7 +756,11 @@ pub async fn cmd_send_message(
&media_tags,
&emoji_tags,
)
.map_err(|e| CliError::Other(format!("build_message failed: {e}")))?
.map_err(|e| CliError::Other(format!("build_message failed: {e}")))?;
match sent_from_thread_tag {
Some(tag) => builder.tag(tag),
None => builder,
}
}
Some(k) => {
return Err(CliError::Usage(format!(
@@ -918,6 +972,7 @@ pub async fn dispatch(
broadcast,
files,
mentions,
from_thread,
} => {
cmd_send_message(
client,
@@ -929,6 +984,7 @@ pub async fn dispatch(
broadcast,
files,
mentions,
from_thread,
},
)
.await
@@ -1079,8 +1135,8 @@ mod tests {
channel_id_from_event, cmd_get_thread, cmd_send_message, event_mention_pubkeys,
find_root_from_tags, match_profiles_by_name, merge_message_mentions, missing_members,
normalize_explicit_mentions, parse_member_pubkeys, resolve_names_to_pubkeys,
resolve_thread_target, thread_ref_from_event, thread_ref_from_parent_tags, BuzzClient,
CliError, Uuid,
resolve_thread_target, sent_from_thread_tag_for_root, thread_ref_from_event,
thread_ref_from_parent_tags, BuzzClient, CliError, Uuid,
};
use buzz_sdk::mentions::{
extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile,
@@ -1142,6 +1198,32 @@ mod tests {
);
}
#[test]
fn from_thread_accepts_only_top_level_kind_9_roots_in_the_channel() {
let channel = "123e4567-e89b-12d3-a456-426614174000";
let other = "123e4567-e89b-12d3-a456-426614174001";
let channel_uuid = Uuid::parse_str(channel).unwrap();
let root = |kind: u64, h: &str| json!({"kind": kind, "content": "hi", "tags": [["h", h]]});
let tag = sent_from_thread_tag_for_root(channel_uuid, ID_A, &root(9, channel)).unwrap();
assert_eq!(tag.as_slice()[1], ID_A);
for kind in [40002, 40008, 45001, 45003] {
let err = sent_from_thread_tag_for_root(channel_uuid, ID_A, &root(kind, channel))
.unwrap_err();
assert!(matches!(err, CliError::Usage(_)), "kind {kind}");
assert!(err.to_string().contains("kind 9"), "kind {kind}");
}
let no_kind = json!({"content": "hi", "tags": [["h", channel]]});
assert!(sent_from_thread_tag_for_root(channel_uuid, ID_A, &no_kind).is_err());
assert!(sent_from_thread_tag_for_root(channel_uuid, ID_A, &root(9, other)).is_err());
let reply = json!({"kind": 9, "tags": [["h", channel], ["e", ID_B, "", "reply"]]});
assert!(sent_from_thread_tag_for_root(channel_uuid, ID_A, &reply)
.unwrap_err()
.to_string()
.contains(ID_B));
}
#[test]
fn selected_event_requires_a_valid_channel_tag() {
let missing = json!({"tags": []});
@@ -1682,6 +1764,7 @@ mod tests {
broadcast: false,
files: vec![],
mentions: vec![],
from_thread: None,
}
}
@@ -1853,4 +1936,147 @@ mod tests {
"palette-error fallback must produce no emoji tags, got: {emoji_tags:?}"
);
}
/// Fake relay that answers `/query` by `ids` filter from `stored` and
/// records every `/events` body.
async fn fake_thread_relay(
stored: Vec<serde_json::Value>,
) -> (String, StdArc<std::sync::Mutex<Vec<String>>>) {
type S = (
Vec<serde_json::Value>,
StdArc<std::sync::Mutex<Vec<String>>>,
);
let published = StdArc::new(std::sync::Mutex::new(Vec::new()));
let app = AxumRouter::new()
.route(
"/query",
axum_post(
|AxumState((stored, _)): AxumState<S>, body: AxumBytes| async move {
let filter: serde_json::Value = serde_json::from_slice(&body).unwrap();
let filter = filter.get(0).unwrap_or(&filter);
let found: Vec<_> = stored
.iter()
.filter(|e| {
filter["ids"]
.as_array()
.is_some_and(|ids| ids.contains(&e["id"]))
})
.collect();
(
AxumStatusCode::OK,
[("content-type", "application/json")],
serde_json::to_string(&found).unwrap(),
)
},
),
)
.route(
"/events",
axum_post(
|AxumState((_, published)): AxumState<S>, body: AxumBytes| async move {
published
.lock()
.unwrap()
.push(String::from_utf8_lossy(&body).to_string());
(
AxumStatusCode::OK,
[("content-type", "application/json")],
r#"{"event_id":"fake0000","accepted":true}"#,
)
},
),
)
.with_state((stored, published.clone()));
let listener = TokioTcpListener::bind("127.0.0.1:0").await.unwrap();
let addr: StdSocketAddr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(format!("http://{addr}"), published)
}
fn stored_message(
id: &str,
channel: &str,
content: &str,
extra: &[serde_json::Value],
) -> serde_json::Value {
let mut tags = vec![serde_json::json!(["h", channel])];
tags.extend(extra.iter().cloned());
serde_json::json!({ "id": id, "kind": 9, "content": content, "tags": tags })
}
#[tokio::test]
async fn send_from_thread_publishes_a_top_level_tagged_message() {
let root = "a".repeat(64);
let reply = "b".repeat(64);
let other_channel = "223e4567-e89b-12d3-a456-426614174000";
let (url, published) = fake_thread_relay(vec![
stored_message(
&root,
SEND_TEST_CHANNEL,
"**Plan**: see [doc](https://x.io) 😀",
&[],
),
stored_message(
&reply,
SEND_TEST_CHANNEL,
"reply",
&[serde_json::json!(["e", root, "", "reply"])],
),
stored_message(&"c".repeat(64), other_channel, "elsewhere", &[]),
])
.await;
let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap();
let send = |from_thread: &str, kind: Option<u16>| {
let mut params = send_params("decision: ship it");
params.from_thread = Some(from_thread.to_string());
params.kind = kind;
cmd_send_message(&client, params)
};
for (target, kind) in [
(reply.as_str(), None),
("c".repeat(64).as_str(), None),
("d".repeat(64).as_str(), None),
("not-hex", None),
(root.as_str(), Some(45001)),
] {
let err = send(target, kind).await.unwrap_err();
let expected = match target {
t if t == "d".repeat(64) => matches!(err, CliError::NotFound(_)),
_ => matches!(err, CliError::Usage(_)),
};
assert!(expected, "{target} {kind:?}: {err}");
if target == reply {
assert!(err.to_string().contains(&root), "{err}");
}
}
assert!(
published.lock().unwrap().is_empty(),
"failed checks must not publish"
);
send(&root.to_uppercase(), None).await.unwrap();
let bodies = published.lock().unwrap();
assert_eq!(bodies.len(), 1, "one send is one publish");
let event: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
assert_eq!(event["kind"], 9);
assert_eq!(event["content"], "decision: ship it");
let tags = event["tags"].as_array().unwrap();
assert!(tags.contains(&serde_json::json!(["h", SEND_TEST_CHANNEL])));
assert!(
tags.contains(&serde_json::json!([
"buzz:sent-from-thread",
root,
"Plan : see doc 😀"
])),
"{tags:?}"
);
assert!(
!tags
.iter()
.any(|t| matches!(t[0].as_str(), Some("e" | "broadcast"))),
"must be top-level: {tags:?}"
);
}
}

View File

@@ -24,6 +24,7 @@ pub mod reactions;
pub(crate) mod read_state;
mod repo_default_branch;
pub mod repos;
pub(crate) mod sent_from_thread;
pub mod social;
pub mod upload;
pub mod users;

View File

@@ -0,0 +1,255 @@
//! `buzz:sent-from-thread` provenance tag, mirroring desktop's
//! `features/messages/lib/sentFromThread.ts` and the validation in
//! `desktop/src-tauri/src/events/message_tags.rs`.
use nostr::{Tag, TagKind};
const TAG_NAME: &str = "buzz:sent-from-thread";
const EXCERPT_MAX_CHARS: usize = 64;
const MIN_WORD_BREAK_UTF16: usize = 32;
/// JavaScript `\s` / `String.prototype.trim` whitespace.
fn is_js_space(c: char) -> bool {
c.is_whitespace() || c == '\u{feff}'
}
fn starts_with_at(chars: &[char], at: usize, pat: &str) -> bool {
pat.chars()
.enumerate()
.all(|(i, p)| chars.get(at + i) == Some(&p))
}
fn find_from(chars: &[char], from: usize, pat: &str) -> Option<usize> {
(from..chars.len()).find(|&i| starts_with_at(chars, i, pat))
}
/// Match `||…||` (no `||` inside) at `at`; returns the end index.
fn spoiler_end(chars: &[char], at: usize) -> Option<usize> {
if !starts_with_at(chars, at, "||") {
return None;
}
find_from(chars, at + 2, "||").map(|close| close + 2)
}
/// Match `[label](target)` at `at`; returns `(end, label_end)` where the label
/// is `at + 1..label_end`. `min_label` is the minimum label length.
fn link_end(chars: &[char], at: usize, min_label: usize) -> Option<(usize, usize)> {
if chars.get(at) != Some(&'[') {
return None;
}
let close = (at + 1..chars.len()).find(|&i| chars[i] == ']')?;
if close - (at + 1) < min_label || chars.get(close + 1) != Some(&'(') {
return None;
}
let paren = (close + 2..chars.len()).find(|&i| chars[i] == ')')?;
Some((paren + 1, close))
}
/// Match `<?https?://\S+>?` at `at`; returns the end index.
fn url_end(chars: &[char], at: usize) -> Option<usize> {
let start = if chars.get(at) == Some(&'<') {
at + 1
} else {
at
};
let after_scheme = if starts_with_at(chars, start, "https://") {
start + 8
} else if starts_with_at(chars, start, "http://") {
start + 7
} else {
return None;
};
let end = (after_scheme..chars.len())
.find(|&i| is_js_space(chars[i]))
.unwrap_or(chars.len());
(end > after_scheme).then_some(end)
}
/// Left-to-right `String.prototype.replace` with a global pattern: `matcher`
/// returns the match end and replacement for a match starting at the index.
fn replace_all(
chars: &[char],
matcher: impl Fn(&[char], usize) -> Option<(usize, Vec<char>)>,
) -> Vec<char> {
let mut out = Vec::with_capacity(chars.len());
let mut i = 0;
while i < chars.len() {
match matcher(chars, i) {
Some((end, replacement)) => {
out.extend(replacement);
i = end;
}
None => {
out.push(chars[i]);
i += 1;
}
}
}
out
}
fn strip_markdown(content: &str) -> String {
let controls_removed: Vec<char> = content
.chars()
.map(|c| {
let cp = c as u32;
if cp <= 0x1f || (0x7f..=0x9f).contains(&cp) {
' '
} else {
c
}
})
.collect();
let spoilers = replace_all(&controls_removed, |chars, at| {
spoiler_end(chars, at).map(|end| (end, vec![' ']))
});
let images = replace_all(&spoilers, |chars, at| {
if chars[at] != '!' {
return None;
}
link_end(chars, at + 1, 0).map(|(end, _)| (end, vec![' ']))
});
let links = replace_all(&images, |chars, at| {
link_end(chars, at, 1).map(|(end, label_end)| (end, chars[at + 1..label_end].to_vec()))
});
let urls = replace_all(&links, |chars, at| {
url_end(chars, at).map(|end| (end, vec![' ']))
});
urls.into_iter()
.map(|c| if "`*_~>#|".contains(c) { ' ' } else { c })
.collect()
}
fn collapse_whitespace(text: &str) -> String {
text.split(is_js_space)
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
/// Summarize a thread root for the tag excerpt exactly as desktop's
/// `summarizeThreadRoot` does. Returns `None` when nothing readable remains.
pub(crate) fn summarize_thread_root(content: &str) -> Option<String> {
let normalized = collapse_whitespace(&strip_markdown(content));
if normalized.is_empty() {
return None;
}
let chars: Vec<char> = normalized.chars().collect();
if chars.len() <= EXCERPT_MAX_CHARS {
return Some(normalized);
}
let clipped: String = chars[..EXCERPT_MAX_CHARS - 1].iter().collect();
let units: Vec<u16> = clipped.encode_utf16().collect();
let excerpt = match units.iter().rposition(|&u| u == u16::from(b' ')) {
Some(space) if space > MIN_WORD_BREAK_UTF16 => String::from_utf16_lossy(&units[..space]),
_ => clipped,
};
Some(format!("{}…", excerpt.trim_end_matches(is_js_space)))
}
/// Build the `["buzz:sent-from-thread", <root-id>, <excerpt?>]` tag.
pub(crate) fn build_tag(root_event_id: &str, excerpt: Option<&str>) -> Tag {
let mut parts = vec![root_event_id.to_string()];
parts.extend(excerpt.map(str::to_string));
Tag::custom(TagKind::custom(TAG_NAME), parts)
}
#[cfg(test)]
mod tests {
use super::*;
fn s(content: &str) -> Option<String> {
summarize_thread_root(content)
}
#[test]
fn strips_markdown_like_desktop() {
assert_eq!(s(" hello\n\tworld ").as_deref(), Some("hello world"));
assert_eq!(
s("**bold** _it_ `code` ~~x~~ > q # h").as_deref(),
Some("bold it code x q h")
);
assert_eq!(
s("see [the doc](https://x.io/a) now").as_deref(),
Some("see the doc now")
);
assert_eq!(s("a ![alt](https://x.io/i.png) b").as_deref(), Some("a b"));
assert_eq!(s("a ||secret | x|| b").as_deref(), Some("a b"));
assert_eq!(s("a ||unclosed b").as_deref(), Some("a unclosed b"));
assert_eq!(
s("go <https://x.io/a?b=1> and http://y.io z").as_deref(),
Some("go and z")
);
assert_eq!(s("[](x) [a]b").as_deref(), Some("[](x) [a]b"));
assert_eq!(s("a\u{0}b\u{7f}c\u{85}d").as_deref(), Some("a b c d"));
assert_eq!(s("a\u{feff}b\u{a0}c").as_deref(), Some("a b c"));
assert_eq!(s("*** ||x|| ![i](u)"), None);
assert_eq!(s(""), None);
}
#[test]
fn clips_at_the_desktop_boundaries() {
let exact = "a".repeat(64);
assert_eq!(s(&exact).as_deref(), Some(exact.as_str()));
let unbroken = "a".repeat(65);
assert_eq!(s(&unbroken), Some(format!("{}…", "a".repeat(63))));
let words = format!("{} {}", "a".repeat(40), "b".repeat(40));
assert_eq!(s(&words), Some(format!("{}…", "a".repeat(40))));
let early_space = format!("{} {}", "a".repeat(32), "b".repeat(40));
assert_eq!(
s(&early_space),
Some(format!("{} {}…", "a".repeat(32), "b".repeat(30)))
);
let late_space = format!("{} {}", "a".repeat(33), "b".repeat(40));
assert_eq!(s(&late_space), Some(format!("{}…", "a".repeat(33))));
}
#[test]
fn clips_by_code_point_and_breaks_by_utf16_unit() {
let wide = "é".repeat(65);
assert_eq!(s(&wide), Some(format!("{}…", "é".repeat(63))));
let emoji = "😀".repeat(65);
assert_eq!(s(&emoji), Some(format!("{}…", "😀".repeat(63))));
let kept = "😀".repeat(64);
assert_eq!(s(&kept).as_deref(), Some(kept.as_str()));
// 16 emoji take 32 UTF-16 units, so the space sits at unit 32: kept whole.
let at_32 = format!("{} {}", "😀".repeat(16), "b".repeat(60));
assert_eq!(
s(&at_32),
Some(format!("{} {}…", "😀".repeat(16), "b".repeat(46)))
);
// 17 emoji put the space at unit 34 (> 32), though only 17 code points in: break there.
let at_34 = format!("{} {}", "😀".repeat(17), "b".repeat(60));
assert_eq!(s(&at_34), Some(format!("{}…", "😀".repeat(17))));
}
#[test]
fn matches_desktop_summarize_thread_root_on_generated_vectors() {
let vectors: Vec<serde_json::Value> =
serde_json::from_str(include_str!("sent_from_thread_vectors.json")).unwrap();
assert!(vectors.len() > 600);
for vector in vectors {
let input = vector["input"].as_str().unwrap();
assert_eq!(
s(input).as_deref(),
vector["expected"].as_str(),
"input: {input:?}"
);
}
}
#[test]
fn tag_has_desktop_shape() {
let id = "a".repeat(64);
assert_eq!(build_tag(&id, None).as_slice(), [TAG_NAME, id.as_str()]);
assert_eq!(
build_tag(&id, Some("hi")).as_slice(),
[TAG_NAME, id.as_str(), "hi"]
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -463,7 +463,7 @@ buzz agents archived"
pub enum MessagesCmd {
/// Send a message to a channel
#[command(
after_help = "Examples:\n buzz messages send --channel <UUID> --content \"hello\"\n buzz messages send --channel <UUID> --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel <UUID> --content -"
after_help = "Examples:\n buzz messages send --channel <UUID> --content \"hello\"\n buzz messages send --channel <UUID> --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel <UUID> --content -\n buzz messages send --channel <UUID> --content \"decision: ship it\" --from-thread <ROOT_EVENT_ID>"
)]
Send {
/// Channel UUID (from 'buzz channels list')
@@ -478,6 +478,9 @@ pub enum MessagesCmd {
/// Event ID to reply to (creates a thread)
#[arg(long)]
reply_to: Option<String>,
/// Thread root event ID: send this as a new top-level message tagged as sent from that thread (like desktop's "Send to channel")
#[arg(long, conflicts_with_all = ["reply_to", "broadcast"])]
from_thread: Option<String>,
/// Also publish to the Nostr network
#[arg(long, default_value_t = false)]
broadcast: bool,
@@ -2433,6 +2436,29 @@ mod tests {
Cli::command().debug_assert();
}
#[test]
fn messages_send_from_thread_conflicts_with_reply_and_broadcast() {
let base = [
"buzz",
"messages",
"send",
"--channel",
"123e4567-e89b-12d3-a456-426614174000",
"--content",
"x",
"--from-thread",
];
let with = |extra: &[&'static str]| {
let mut args = base.to_vec();
args.push("a");
args.extend(extra);
Cli::try_parse_from(args)
};
assert!(with(&[]).is_ok());
assert!(with(&["--reply-to", "b"]).is_err());
assert!(with(&["--broadcast"]).is_err());
}
#[test]
fn messages_thread_accepts_link_or_explicit_identifiers() {
let channel = "123e4567-e89b-12d3-a456-426614174000";