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
1 change: 1 addition & 0 deletions crates/buzz-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export BUZZ_RELAY_URL="https://relay.example.com"
# Messages
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-file report.md # read body directly from a UTF-8 file
buzz messages send --channel <uuid> --content - < message.md # read body from stdin
buzz messages get --channel <uuid> --limit 20
buzz messages thread --channel <uuid> --event <event-id>
Expand Down
3 changes: 3 additions & 0 deletions crates/buzz-cli/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ buzz messages send --channel "$CHANNEL_ID" \
echo 'Body with `backticks` and $vars stays literal.' \
| buzz messages send --channel "$CHANNEL_ID" --content - | jq .

# messages send directly from a UTF-8 file — no shell redirection required
buzz messages send --channel "$CHANNEL_ID" --content-file message.md | jq .

# messages get
buzz messages get --channel "$CHANNEL_ID" | jq .
buzz messages get --channel "$CHANNEL_ID" --limit 5 | jq .
Expand Down
101 changes: 85 additions & 16 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -561,41 +561,53 @@ fn match_profiles_by_name(events: &[serde_json::Value], name: &str) -> Vec<(Stri
matches
}

fn resolve_message_content(
content: Option<&str>,
content_file: Option<&str>,
) -> Result<String, CliError> {
match (content, content_file) {
(Some(value), None) => read_or_stdin(value),
(None, Some(path)) => std::fs::read_to_string(path).map_err(|e| {
CliError::Usage(format!("failed to read message content file {path:?}: {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 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>,
}

pub async fn cmd_send_message(
client: &BuzzClient,
mut p: SendMessageParams,
) -> Result<(), CliError> {
// Allow '-' to read content from stdin. This keeps callers from having to
// 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)?;
pub async fn cmd_send_message(client: &BuzzClient, p: SendMessageParams) -> Result<(), CliError> {
let content = resolve_message_content(p.content.as_deref(), p.content_file.as_deref())?;
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 @@ -628,9 +640,9 @@ pub async fn cmd_send_message(
media_content.push(')');
}
let final_content = if media_content.is_empty() {
p.content.clone()
content.clone()
} else {
format!("{}{media_content}", p.content)
format!("{content}{media_content}")
};

// Build thread ref if replying. `--reply-to` is the immediate parent; the
Expand Down Expand Up @@ -875,6 +887,7 @@ pub async fn dispatch(
MessagesCmd::Send {
channel,
content,
content_file,
kind,
reply_to,
broadcast,
Expand All @@ -886,6 +899,7 @@ pub async fn dispatch(
SendMessageParams {
channel_id: channel,
content,
content_file,
kind,
reply_to,
broadcast,
Expand Down Expand Up @@ -995,7 +1009,7 @@ mod tests {
use super::{
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_message_content, resolve_names_to_pubkeys,
};
use buzz_sdk::mentions::{
extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile,
Expand All @@ -1012,6 +1026,61 @@ mod tests {
const PK_VALID_B: &str = "c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05";
const PK_VALID_C: &str = "f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68";

#[test]
fn message_content_file_reads_utf8_verbatim() {
let path = std::env::temp_dir().join(format!(
"buzz-message-content-{}-{}.md",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let expected = "# Review\n\nLiteral `code` and $variables.\n";
std::fs::write(&path, expected).unwrap();

let actual = resolve_message_content(None, path.to_str()).unwrap();

std::fs::remove_file(path).unwrap();
assert_eq!(actual, expected);
}

#[test]
fn message_content_file_reports_missing_file_as_usage_error() {
let error =
resolve_message_content(None, Some("missing-message-content-file.md")).unwrap_err();
assert!(matches!(error, crate::error::CliError::Usage(_)));
assert!(error.to_string().contains("message content file"));
}

#[test]
fn message_content_file_rejects_invalid_utf8() {
let path = std::env::temp_dir().join(format!(
"buzz-message-content-invalid-utf8-{}-{}.md",
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 = resolve_message_content(None, path.to_str()).unwrap_err();

std::fs::remove_file(path).unwrap();
assert!(matches!(error, crate::error::CliError::Usage(_)));
assert!(error
.to_string()
.to_ascii_lowercase()
.contains("valid utf-8"));
}

#[test]
fn message_content_source_rejects_both_or_neither() {
assert!(resolve_message_content(Some("inline"), Some("message.md")).is_err());
assert!(resolve_message_content(None, None).is_err());
}

#[test]
fn root_marker_wins_over_reply_marker() {
let tags = json!([
Expand Down
47 changes: 44 additions & 3 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ mod error;
mod links;
mod validate;

use clap::{Parser, Subcommand};
use clap::{ArgGroup, Parser, Subcommand};
use client::BuzzClient;
use error::CliError;
use nostr::Keys;
Expand Down Expand Up @@ -352,15 +352,22 @@ 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-file report.md",
group = ArgGroup::new("message_content")
.required(true)
.multiple(false)
.args(["content", "content_file"])
)]
Send {
/// Channel UUID (from 'buzz channels list')
#[arg(long)]
channel: String,
/// Message text — supports @mentions and markdown. Use '-' to read from stdin.
#[arg(long)]
content: String,
content: Option<String>,
/// Read the message text from a UTF-8 file.
#[arg(long)]
content_file: Option<String>,
/// Nostr event kind (default: channel default)
#[arg(long)]
kind: Option<u16>,
Expand Down Expand Up @@ -2053,6 +2060,40 @@ mod tests {
Cli::command().debug_assert();
}

#[test]
fn messages_send_accepts_exactly_one_content_source() {
assert!(Cli::try_parse_from([
"buzz",
"messages",
"send",
"--channel",
"550e8400-e29b-41d4-a716-446655440000",
"--content-file",
"report.md",
])
.is_ok());
assert!(Cli::try_parse_from([
"buzz",
"messages",
"send",
"--channel",
"550e8400-e29b-41d4-a716-446655440000",
])
.is_err());
assert!(Cli::try_parse_from([
"buzz",
"messages",
"send",
"--channel",
"550e8400-e29b-41d4-a716-446655440000",
"--content",
"inline",
"--content-file",
"report.md",
])
.is_err());
}

#[test]
fn set_status_clear_rejects_text_and_emoji() {
for extra in [["--text", "busy"], ["--emoji", "🎶"]] {
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/managed_agents/nest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const NEST_AGENTS_VERSION: u32 = 4;

/// Template content version for SKILL.md.
/// Bump this when changing `nest_skill.md` to trigger refresh on existing installs.
const NEST_SKILL_VERSION: u32 = 5;
const NEST_SKILL_VERSION: u32 = 6;

const BEGIN_MARKER: &str = "<!-- BEGIN BUZZ MANAGED";
const END_MARKER: &str = "<!-- END BUZZ MANAGED -->";
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/nest/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ fn nest_skill_contains_safe_mention_workflow() {
assert!(BUZZ_CLI_SKILL_MD.contains("no follow-up verification command is needed"));
assert!(BUZZ_CLI_SKILL_MD.contains("Add membership separately only when authorized"));
assert!(BUZZ_CLI_SKILL_MD.contains("never changes membership automatically"));
assert!(BUZZ_CLI_SKILL_MD.contains("--content-file reports/review.md"));
}

#[test]
Expand Down
12 changes: 12 additions & 0 deletions desktop/src-tauri/src/managed_agents/nest_skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@ buzz messages send --channel <UUID> \
--content "@Alice check this" --mention <alice-pubkey>
```

When the complete message already exists in a UTF-8 file, use `--content-file`
so the direct `buzz messages send` command does not need shell redirection,
command substitution, or a pipeline:

```bash
buzz messages send --channel <UUID> \
--reply-to <event-id> --content-file reports/review.md
```

`--content` and `--content-file` are mutually exclusive. The existing
`--content -` form remains available for stdin.

## DM Management

`dms hide --channel <UUID>` hides a DM from the agent's DM list. Restore by re-opening with `dms open --pubkey <hex>`.
Expand Down