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
7 changes: 6 additions & 1 deletion crates/buzz-acp/src/base_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ Auth env vars: `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`. Exit codes
0 ok, 1 user error, 2 network, 3 auth, 4 other, 5 write conflict. Output is
structured JSON. `--format compact` is global — it goes before the subcommand.

Run `buzz --help` or `buzz <group> --help` for full usage. For multiline message content, pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat.
For multiline message content, pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`.
For long or multiline Unicode content, you can also write a UTF-8 file and use `buzz messages send --content-file <path>`.
The CLI validates the file as UTF-8 and reports an error if it is invalid.

`buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`;
if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat.

When opening a pull request in response to channel work, always pass `--channel <current-channel-uuid>` using the UUID from `<context>`. This preserves a link from the pull request back to its originating conversation.

Expand Down
3 changes: 2 additions & 1 deletion crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5278,7 +5278,8 @@ mod agent_draft_prompt_tests {
fn shared_base_prompt_teaches_real_newlines_for_multiline_messages() {
let prompt = include_str!("base_prompt.md");
assert!(prompt.contains("pass real newline bytes through stdin"));
assert!(prompt.contains("single-quoted shell strings preserve `\\n` literally"));
assert!(prompt.contains("--content-file <path>"));
assert!(prompt.contains("validates the file as UTF-8"));
assert!(prompt.contains("buzz messages send ... --content -"));
}

Expand Down
92 changes: 84 additions & 8 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -600,14 +600,37 @@ fn match_profiles_by_name(events: &[serde_json::Value], name: &str) -> Vec<(Stri

pub struct SendMessageParams {
pub channel_id: String,
pub content: String,
pub content: Option<String>,
pub content_file: Option<String>,
pub kind: Option<u16>,
pub reply_to: Option<String>,
pub broadcast: bool,
pub files: Vec<String>,
pub mentions: Vec<String>,
}

fn resolve_message_content(
content: Option<String>,
content_file: Option<String>,
) -> Result<String, CliError> {
match (content, content_file) {
(Some(content), None) => read_or_stdin(&content),
(None, Some(path)) => {
let bytes = std::fs::read(&path)
.map_err(|e| CliError::Usage(format!("failed to read {path:?}: {e}")))?;
String::from_utf8(bytes).map_err(|e| {
CliError::Usage(format!("message file {path:?} is not valid UTF-8: {e}"))
})
}
(Some(_), Some(_)) => Err(CliError::Usage(
"--content and --content-file cannot be used together".into(),
)),
(None, None) => Err(CliError::Usage(
"one of --content or --content-file is required".into(),
)),
}
}

pub async fn cmd_send_message(
client: &BuzzClient,
mut p: SendMessageParams,
Expand All @@ -616,23 +639,31 @@ pub async fn cmd_send_message(
// jam shell-metacharacter-heavy text (backticks, $vars, etc.) through argv
// quoting — the source of countless self-inflicted command-substitution
// bugs for agent and human users alike.
p.content = read_or_stdin(&p.content)?;
validate_content_size(&p.content)?;
p.content = Some(resolve_message_content(
p.content.take(),
p.content_file.take(),
)?);
let Some(content) = p.content.as_ref() else {
return Err(CliError::Usage(
"one of --content or --content-file is required".into(),
));
};
validate_content_size(content)?;
if let Some(ref r) = p.reply_to {
validate_hex64(r)?;
}
let channel_uuid = parse_uuid(&p.channel_id)?;

let explicit_mentions = normalize_explicit_mentions(&p.mentions)?;
let stripped = strip_code_regions(&p.content);
let stripped = strip_code_regions(content);
let uri_pubkeys = extract_nostr_uris(&stripped);
// Supplying any identity explicitly authorizes unresolved or ambiguous @Name text
// as presentation-only, matching Desktop's separate visible-label and p-tag model.
// Uniquely resolvable member names still add their own p-tags; callers must supply
// every intended identity whose visible label cannot be resolved uniquely.
let has_explicit_mentions = !explicit_mentions.is_empty() || !uri_pubkeys.is_empty();
let (member_pubkeys, auto_resolved) =
resolve_content_mentions(client, &p.channel_id, &p.content, has_explicit_mentions).await?;
resolve_content_mentions(client, &p.channel_id, content, has_explicit_mentions).await?;
let mention_pubkeys = merge_message_mentions(&explicit_mentions, &uri_pubkeys, &auto_resolved)?;

let missing = missing_members(&mention_pubkeys, &member_pubkeys);
Expand Down Expand Up @@ -665,9 +696,9 @@ pub async fn cmd_send_message(
media_content.push(')');
}
let final_content = if media_content.is_empty() {
p.content.clone()
content.to_string()
} else {
format!("{}{media_content}", p.content)
format!("{}{media_content}", content)
};

// Build thread ref if replying. `--reply-to` is the immediate parent; the
Expand Down Expand Up @@ -940,6 +971,7 @@ pub async fn dispatch(
MessagesCmd::Send {
channel,
content,
content_file,
kind,
reply_to,
broadcast,
Expand All @@ -951,6 +983,7 @@ pub async fn dispatch(
SendMessageParams {
channel_id: channel,
content,
content_file,
kind,
reply_to,
broadcast,
Expand Down Expand Up @@ -1711,7 +1744,8 @@ mod tests {
fn send_params(content: &str) -> super::SendMessageParams {
super::SendMessageParams {
channel_id: SEND_TEST_CHANNEL.to_string(),
content: content.to_string(),
content: Some(content.to_string()),
content_file: None,
kind: None,
reply_to: None,
broadcast: false,
Expand All @@ -1720,6 +1754,48 @@ mod tests {
}
}

#[test]
fn resolve_message_content_reads_utf8_file_bytes() {
let path = std::env::temp_dir().join(format!(
"buzz-message-{}-{}.txt",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let expected = "UTF-8-Test: ä ö ü Ä Ö Ü ß „Anführungszeichen“";
std::fs::write(&path, expected.as_bytes()).unwrap();

let actual =
super::resolve_message_content(None, Some(path.to_string_lossy().into_owned()))
.unwrap();
std::fs::remove_file(path).unwrap();

assert_eq!(actual, expected);
}

#[test]
fn resolve_message_content_rejects_non_utf8_file() {
let path = std::env::temp_dir().join(format!(
"buzz-message-invalid-{}-{}.txt",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&path, [0xff, 0xfe]).unwrap();

let error = super::resolve_message_content(None, Some(path.to_string_lossy().into_owned()))
.unwrap_err();
std::fs::remove_file(path).unwrap();

assert!(
matches!(error, crate::error::CliError::Usage(message) if message.contains("not valid UTF-8"))
);
}

#[tokio::test]
async fn cmd_send_message_attaches_emoji_tags_for_known_shortcodes() {
// Content contains `:wave:` which resolves in the palette.
Expand Down
11 changes: 9 additions & 2 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,8 +417,15 @@ pub enum MessagesCmd {
#[arg(long)]
channel: String,
/// Message text — supports @mentions and markdown. Use '-' to read from stdin.
#[arg(long)]
content: String,
#[arg(
long,
conflicts_with = "content_file",
required_unless_present = "content_file"
)]
content: Option<String>,
/// Read message content from a UTF-8 file, avoiding shell pipe encoding.
#[arg(long, conflicts_with = "content", required_unless_present = "content")]
content_file: Option<String>,
/// Nostr event kind (default: channel default)
#[arg(long)]
kind: Option<u16>,
Expand Down