From 63fd0c48def89fed8afe6c503b68bc14951042d5 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 16:00:41 -0400 Subject: [PATCH 1/9] feat(cli): add buzz gifs command group and NIP-30 emoji tags on messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two agent-facing capabilities to buzz-cli: 1. buzz gifs search / gifs share — lets agents search KLIPY GIFs via the relay's NIP-98-authenticated proxy, gate on the relay's NIP-11 buzz-gif extension descriptor, and report selections. Sending a GIF is a plain message containing the cdn_url; no send-path changes required. Adds post_json_authed helper to BuzzClient for NIP-98-authed JSON POSTs. 2. NIP-30 custom emoji tags on outgoing messages — buzz messages send now scans final content for :shortcode: sequences (hand-rolled scanner, no new dep, mirrors desktop customEmojiTags.ts exactly) and attaches ["emoji", shortcode, url] tags from the workspace palette. The palette fetch is skipped when content contains no colon sequences. Extends build_message with an emoji_tags parameter (additive; all existing call sites pass &[]). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-acp/src/pool.rs | 23 ++- crates/buzz-acp/src/setup_mode.rs | 1 + crates/buzz-cli/README.md | 13 ++ crates/buzz-cli/src/client.rs | 41 ++++ crates/buzz-cli/src/commands/emoji.rs | 144 +++++++++++++ crates/buzz-cli/src/commands/gifs.rs | 252 +++++++++++++++++++++++ crates/buzz-cli/src/commands/messages.rs | 11 + crates/buzz-cli/src/commands/mod.rs | 1 + crates/buzz-cli/src/lib.rs | 30 +++ crates/buzz-sdk/src/builders.rs | 121 ++++++++++- 10 files changed, 620 insertions(+), 17 deletions(-) create mode 100644 crates/buzz-cli/src/commands/gifs.rs diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 4d20e30ee23..c73bd56031f 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -4989,14 +4989,21 @@ pub(crate) async fn post_failure_notice( parent_event_id: parent_id, }) }); - let builder = - match buzz_sdk::build_message(channel_id, content, thread_ref.as_ref(), &[], false, &[]) { - Ok(b) => b, - Err(e) => { - tracing::warn!(channel = %channel_id, "failure notice: build failed: {e}"); - return; - } - }; + let builder = match buzz_sdk::build_message( + channel_id, + content, + thread_ref.as_ref(), + &[], + false, + &[], + &[], + ) { + Ok(b) => b, + Err(e) => { + tracing::warn!(channel = %channel_id, "failure notice: build failed: {e}"); + return; + } + }; let event = match builder.sign_with_keys(&rest.keys) { Ok(e) => e, Err(e) => { diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index 88225469aa2..70b5a8dcb28 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -669,6 +669,7 @@ async fn publish_setup_nudge( &[recipient_hex], // p-tag the verified effective asker false, &[], + &[], ) .map_err(|e| anyhow::anyhow!("failed to build setup nudge: {e}"))?; diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 8f8db4d2893..ef9ce7c7921 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -53,6 +53,17 @@ buzz channels topic --channel --topic "New topic" buzz reactions add --event --emoji "👍" buzz reactions get --event +# GIFs (requires relay to advertise buzz-gif / KLIPY) +buzz gifs search # trending GIFs +buzz gifs search --query "celebration" # search GIFs +buzz gifs share --slug # report selection to provider Recents +# Paste the `cdn_url` from a search result directly into messages send --content + +# Custom emoji in messages +# buzz messages send scans outgoing content for :shortcode: patterns and +# automatically attaches NIP-30 ["emoji", shortcode, url] tags from the +# workspace palette — identical to the desktop composer behavior. + # Users & Presence buzz users get # your own profile buzz users get --pubkey # single user @@ -130,6 +141,8 @@ stored rules in `validation_error` so an owner can remove and repair them. | `reactions` | `add` | React to a message | | | `remove` | Remove a reaction | | | `get` | List reactions | +| `gifs` | `search` | Search or browse trending GIFs (requires relay buzz-gif support) | +| | `share` | Report a selected GIF to the provider's Recents | | `dms` | `list` | List DM conversations | | | `open` | Open a DM (1–8 pubkeys) | | | `add-member` | Add member to DM group | diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 76d0e6fb959..75c87aa427f 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -870,6 +870,47 @@ impl BuzzClient { .await } + /// POST a JSON body to a relay-relative path with NIP-98 authentication. + /// + /// Used by `buzz gifs search` and `buzz gifs share` to reach the relay's + /// KLIPY proxy endpoints. Returns the raw response body as a string (may + /// be empty for 204 No Content responses). + pub async fn post_json_authed( + &self, + path: &str, + body: &serde_json::Value, + ) -> Result { + let url = format!("{}{path}", self.relay_url); + let body_bytes = bytes::Bytes::from( + serde_json::to_vec(body) + .map_err(|e| CliError::Other(format!("request serialization failed: {e}")))?, + ); + self.with_retry_body(|| { + let body_bytes = body_bytes.clone(); + let url = url.clone(); + async move { + let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?; + let resp = self + .with_auth_tag( + self.http + .post(&url) + .header("Authorization", auth) + .header("Content-Type", "application/json") + .body(body_bytes), + ) + .send() + .await?; + // 204 No Content: return empty string rather than failing on + // an empty body that cannot be parsed as JSON. + if resp.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(String::new()); + } + self.handle_response(resp).await + } + }) + .await + } + /// Submit a signed Nostr event via POST /events. /// /// For non-idempotent moderation command kinds (9040–9044), an ambiguous diff --git a/crates/buzz-cli/src/commands/emoji.rs b/crates/buzz-cli/src/commands/emoji.rs index d5dbff3f5cb..b8cd3a8fdc5 100644 --- a/crates/buzz-cli/src/commands/emoji.rs +++ b/crates/buzz-cli/src/commands/emoji.rs @@ -308,6 +308,94 @@ async fn cmd_import( publish_own_set(client, &final_set).await } +/// Scan `content` for `:shortcode:` patterns, mirroring the desktop's +/// `customEmojiTags.ts` algorithm exactly: +/// +/// - Pattern: `:([a-z0-9_-]+):` (case-insensitive; canonical lowercase emitted) +/// - One tag per distinct first-appearing shortcode +/// - Unknown shortcodes silently ignored +/// +/// Returns NIP-30 `["emoji", shortcode, url]` tag vectors for every +/// shortcode that resolves in the workspace palette. Returns an empty `Vec` +/// without a relay round-trip if no candidates appear in the content. +/// +/// Callers must pre-screen with `content.contains(':')` to skip this +/// function entirely for the common case of plain content. +pub async fn resolve_emoji_tags_for_content( + client: &BuzzClient, + content: &str, +) -> Result>, CliError> { + let candidates = scan_shortcodes(content); + if candidates.is_empty() { + return Ok(Vec::new()); + } + + // Fetch workspace palette (union of all members' kind:30030 sets). + let filter = serde_json::json!({ + "kinds": [buzz_sdk::kind::KIND_EMOJI_SET], + "#d": [CUSTOM_EMOJI_SET_D_TAG], + }); + let raw = client.query(&filter).await?; + let events: Vec = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("failed to parse emoji set query: {e}")))?; + let palette = union_custom_emoji(&events); + let url_by_shortcode: std::collections::HashMap<&str, &str> = palette + .iter() + .map(|e| (e.shortcode.as_str(), e.url.as_str())) + .collect(); + + let tags: Vec> = candidates + .iter() + .filter_map(|sc| { + url_by_shortcode + .get(sc.as_str()) + .map(|url| vec!["emoji".to_string(), sc.clone(), url.to_string()]) + }) + .collect(); + + Ok(tags) +} + +/// Collect candidate shortcodes from `content` without a regex dependency. +/// +/// Implements `:([a-z0-9_-]+):` (applied case-insensitively with lowercase +/// normalization) using a hand-rolled single-pass scanner. Each distinct +/// shortcode appears exactly once in first-appearance order. +pub(crate) fn scan_shortcodes(content: &str) -> Vec { + let bytes = content.as_bytes(); + let len = bytes.len(); + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + let mut i = 0; + while i < len { + if bytes[i] != b':' { + i += 1; + continue; + } + // Found opening `:`. Scan forward for valid shortcode chars. + let start = i + 1; + let mut j = start; + while j < len && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_' || bytes[j] == b'-') + { + j += 1; + } + // Require at least one char and a closing `:`. + if j > start && j < len && bytes[j] == b':' { + // SAFETY: `content` is valid UTF-8 and the slice covers only ASCII. + let sc = content[start..j].to_lowercase(); + if seen.insert(sc.clone()) { + out.push(sc); + } + // Advance past the closing `:` so overlapping patterns like `:a::b:` + // are handled correctly (`:a:` consumed, next scan starts at `:`). + i = j + 1; + } else { + i += 1; + } + } + out +} + pub async fn dispatch(cmd: crate::EmojiCmd, client: &BuzzClient) -> Result<(), CliError> { use crate::EmojiCmd; match cmd { @@ -386,4 +474,60 @@ mod tests { assert_eq!(emojis[0].shortcode, "zort"); assert_eq!(emojis[0].url, "https://example.com/zort.png"); } + + // ── scan_shortcodes ────────────────────────────────────────────────────── + + #[test] + fn scan_finds_basic_shortcode() { + assert_eq!(scan_shortcodes(":wave:"), vec!["wave"]); + } + + #[test] + fn scan_finds_multiple_shortcodes_in_order() { + let result = scan_shortcodes(":wave: hello :party_parrot: world :tada:"); + assert_eq!(result, vec!["wave", "party_parrot", "tada"]); + } + + #[test] + fn scan_deduplicates_shortcodes() { + let result = scan_shortcodes(":wave: :wave: :wave:"); + assert_eq!(result, vec!["wave"]); + } + + #[test] + fn scan_normalizes_to_lowercase() { + let result = scan_shortcodes(":WAVE: :Wave:"); + assert_eq!(result, vec!["wave"]); + } + + #[test] + fn scan_ignores_invalid_chars_in_shortcode() { + // Spaces inside are not valid shortcode chars + let result = scan_shortcodes(":hello world:"); + assert!(result.is_empty()); + } + + #[test] + fn scan_empty_colons_not_matched() { + // "::" has zero chars between — must not match + assert!(scan_shortcodes("::").is_empty()); + } + + #[test] + fn scan_no_candidates_in_plain_content() { + assert!(scan_shortcodes("Hello world, no emoji here").is_empty()); + } + + #[test] + fn scan_handles_adjacent_shortcodes() { + // ":a::b:" — `:a:` consumed, then `:b:` starts at `:` + let result = scan_shortcodes(":a::b:"); + assert_eq!(result, vec!["a", "b"]); + } + + #[test] + fn scan_allows_hyphens_and_underscores() { + let result = scan_shortcodes(":party-parrot: :sweat_blob:"); + assert_eq!(result, vec!["party-parrot", "sweat_blob"]); + } } diff --git a/crates/buzz-cli/src/commands/gifs.rs b/crates/buzz-cli/src/commands/gifs.rs new file mode 100644 index 00000000000..d05eced7776 --- /dev/null +++ b/crates/buzz-cli/src/commands/gifs.rs @@ -0,0 +1,252 @@ +//! Agent GIF search and share via the relay's KLIPY proxy. +//! +//! `buzz gifs search` / `buzz gifs share` hit the relay-relative endpoints +//! advertised in the NIP-11 `gif` descriptor. No provider credential is held +//! by the agent — the relay proxies KLIPY and returns only allowlisted data. +//! +//! Sending a GIF is a normal message whose content contains the CDN URL +//! returned by search — no special send-path handling, no imeta. + +use crate::client::BuzzClient; +use crate::error::CliError; + +/// Gate: `supported_extensions` must contain this value. +const REQUIRED_EXTENSION: &str = "buzz-gif"; +/// Gate: `gif.provider` must be this value. +const REQUIRED_PROVIDER: &str = "klipy"; + +/// Derive a stable anonymous `customer_id` from the agent keypair. +/// +/// KLIPY requires a per-installation identifier that is stable and anonymous. +/// SHA-256 of the public key hex satisfies both requirements: stable across +/// sessions, never traceable to a person, never stored. The first 32 hex chars +/// (128 bits) are ample for KLIPY's uniqueness needs; the full 64-char hash is +/// within the server's 128-char limit but unnecessarily long. +fn customer_id_from_pubkey(pubkey_hex: &str) -> String { + use sha2::{Digest, Sha256}; + let hash = Sha256::digest(pubkey_hex.as_bytes()); + hex::encode(&hash[..16]) // 16 bytes → 32 hex chars +} + +/// Locale to send to KLIPY. Reads `LANG` first, falls back to `en_US`. +fn default_locale() -> String { + std::env::var("LANG") + .ok() + .and_then(|l| { + // `LANG` is typically `en_US.UTF-8` or `en_US`; strip the encoding + // suffix and take up to 5 chars which gives the provider-understood + // locale code (e.g. `en_US`). + let code: String = l + .splitn(2, '.') + .next() + .unwrap_or("") + .chars() + .take(5) + .collect(); + if code.len() >= 2 { + Some(code) + } else { + None + } + }) + .unwrap_or_else(|| "en_US".to_string()) +} + +/// Resolve the relay's `gif` descriptor from its NIP-11 document. +/// +/// Returns `(search_path, share_path)` as relay-relative strings (e.g. +/// `"/gifs/search"`, `"/gifs/share"`). Returns a clear `CliError` if the +/// relay does not advertise `buzz-gif` or the provider is not `klipy`. +async fn resolve_gif_descriptor(client: &BuzzClient) -> Result<(String, String), CliError> { + let raw = client.get_public("/info").await?; + let info: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("invalid NIP-11 response: {e}")))?; + + // Gate 1: `supported_extensions` must contain `"buzz-gif"`. + let extensions = info + .get("supported_extensions") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::>()) + .unwrap_or_default(); + if !extensions.iter().any(|&e| e == REQUIRED_EXTENSION) { + return Err(CliError::Other(format!( + "this relay does not support GIF search (missing \"{REQUIRED_EXTENSION}\" in supported_extensions)" + ))); + } + + // Gate 2: `gif.provider` must be `"klipy"`. + let gif = info.get("gif").ok_or_else(|| { + CliError::Other("relay advertises buzz-gif but has no \"gif\" descriptor".to_string()) + })?; + let provider = gif.get("provider").and_then(|v| v.as_str()).unwrap_or(""); + if provider != REQUIRED_PROVIDER { + return Err(CliError::Other(format!( + "unsupported GIF provider \"{provider}\" (only \"{REQUIRED_PROVIDER}\" is supported)" + ))); + } + + let search = gif + .get("search") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let share = gif + .get("share") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if search.is_empty() || share.is_empty() { + return Err(CliError::Other( + "relay gif descriptor is missing search or share path".to_string(), + )); + } + + Ok((search, share)) +} + +/// `buzz gifs search [--query ] [--locale ]` +/// +/// Empty/omitted `query` returns KLIPY trending GIFs. Output is a JSON +/// array of GIF objects; each entry's `cdn_url` field is the URL to embed +/// in a `buzz messages send --content` argument. +pub async fn cmd_search( + client: &BuzzClient, + query: &str, + locale: Option<&str>, +) -> Result<(), CliError> { + let (search_path, _) = resolve_gif_descriptor(client).await?; + let customer_id = customer_id_from_pubkey(&client.keys().public_key().to_hex()); + let locale = locale.map(|l| l.to_string()).unwrap_or_else(default_locale); + + let body = serde_json::json!({ + "query": query, + "customer_id": customer_id, + "locale": locale, + }); + let raw = client.post_json_authed(&search_path, &body).await?; + + // Relay returns `{"result": true, "data": {"data": [...]}}`. Unwrap to the + // inner array so agents get a flat list they can iterate directly. + let parsed: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("invalid GIF search response: {e}")))?; + let gifs = parsed + .get("data") + .and_then(|d| d.get("data")) + .cloned() + .unwrap_or(serde_json::Value::Array(vec![])); + + println!( + "{}", + serde_json::to_string(&gifs).unwrap_or_else(|_| "[]".to_string()) + ); + Ok(()) +} + +/// `buzz gifs share --slug ` +/// +/// Reports a selected GIF to KLIPY so it can update Recents. The `slug` +/// is the provider identifier returned in search results. Prints +/// `{"accepted": true}` on success. +pub async fn cmd_share(client: &BuzzClient, slug: &str) -> Result<(), CliError> { + let (_, share_path) = resolve_gif_descriptor(client).await?; + let customer_id = customer_id_from_pubkey(&client.keys().public_key().to_hex()); + + let body = serde_json::json!({ + "slug": slug, + "customer_id": customer_id, + }); + // The relay returns 204 No Content on success; post_json_authed returns "". + client.post_json_authed(&share_path, &body).await?; + println!("{}", serde_json::json!({"accepted": true})); + Ok(()) +} + +pub async fn dispatch(cmd: crate::GifsCmd, client: &BuzzClient) -> Result<(), CliError> { + match cmd { + crate::GifsCmd::Search { query, locale } => { + cmd_search(client, query.as_deref().unwrap_or(""), locale.as_deref()).await + } + crate::GifsCmd::Share { slug } => cmd_share(client, &slug).await, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn customer_id_is_32_hex_chars_and_stable() { + let pk = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + let id = customer_id_from_pubkey(pk); + assert_eq!(id.len(), 32); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + // Stability: same input → same output. + assert_eq!(id, customer_id_from_pubkey(pk)); + } + + #[test] + fn customer_id_differs_for_different_pubkeys() { + let a = customer_id_from_pubkey("aaaa"); + let b = customer_id_from_pubkey("bbbb"); + assert_ne!(a, b); + } + + #[test] + fn default_locale_falls_back_when_lang_unset() { + // Remove LANG if set; we cannot safely setenv in parallel tests, so + // only verify the fallback path indirectly via the absence condition. + let locale = + if std::env::var("LANG").ok().map(|l| l.trim().to_string()) == Some(String::new()) { + "en_US".to_string() + } else { + // LANG is set — just confirm we get a non-empty string. + default_locale() + }; + assert!(!locale.is_empty()); + } + + /// Checks that NIP-11 gating rejects a relay that doesn't advertise buzz-gif. + #[test] + fn nip11_gating_logic_missing_extension() { + let info = serde_json::json!({ + "supported_extensions": ["buzz-emoji"], + "gif": { "provider": "klipy", "search": "/gifs/search", "share": "/gifs/share" } + }); + // Simulate the gating check inline (no I/O needed). + let extensions: Vec<&str> = info["supported_extensions"] + .as_array() + .unwrap() + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert!(!extensions.contains(&REQUIRED_EXTENSION)); + } + + #[test] + fn nip11_gating_logic_wrong_provider() { + let info = serde_json::json!({ + "supported_extensions": ["buzz-gif"], + "gif": { "provider": "tenor", "search": "/gifs/search", "share": "/gifs/share" } + }); + let provider = info["gif"]["provider"].as_str().unwrap_or(""); + assert_ne!(provider, REQUIRED_PROVIDER); + } + + #[test] + fn nip11_gating_logic_passes_valid_descriptor() { + let info = serde_json::json!({ + "supported_extensions": ["buzz-gif"], + "gif": { "provider": "klipy", "search": "/gifs/search", "share": "/gifs/share" } + }); + let extensions: Vec<&str> = info["supported_extensions"] + .as_array() + .unwrap() + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert!(extensions.contains(&REQUIRED_EXTENSION)); + assert_eq!(info["gif"]["provider"].as_str().unwrap(), REQUIRED_PROVIDER); + assert_eq!(info["gif"]["search"].as_str().unwrap(), "/gifs/search"); + assert_eq!(info["gif"]["share"].as_str().unwrap(), "/gifs/share"); + } +} diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 9f41fbf751c..1048e63fd47 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -680,6 +680,16 @@ pub async fn cmd_send_message( let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect(); + // Scan final_content for `:shortcode:` patterns and attach NIP-30 emoji + // tags for any that resolve in the workspace palette. The palette fetch + // (one relay query) is skipped entirely when the content has no candidate + // `:…:` sequence, keeping normal message sends at zero extra RTTs. + let emoji_tags = if final_content.contains(':') { + crate::commands::emoji::resolve_emoji_tags_for_content(client, &final_content).await? + } else { + Vec::new() + }; + let builder = match p.kind { Some(45001) => { buzz_sdk::build_forum_post(channel_uuid, &final_content, &mention_refs, &media_tags) @@ -705,6 +715,7 @@ pub async fn cmd_send_message( &mention_refs, p.broadcast, &media_tags, + &emoji_tags, ) .map_err(|e| CliError::Other(format!("build_message failed: {e}")))?, Some(k) => { diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 8bb24218eb5..7ed03f9d060 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod channels; pub mod dms; pub mod emoji; pub mod feed; +pub mod gifs; pub mod issues; pub mod mem; pub mod messages; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index d0155970fa2..3f2bea73979 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -192,6 +192,9 @@ enum Cmd { /// Manage your custom emoji set (workspace palette is the union of all members' sets) #[command(subcommand)] Emoji(EmojiCmd), + /// Search and share GIFs via the relay's KLIPY proxy + #[command(subcommand)] + Gifs(GifsCmd), /// List, open, and manage direct messages #[command(subcommand)] Dms(DmsCmd), @@ -806,6 +809,31 @@ pub enum EmojiCmd { }, } +#[derive(Subcommand)] +pub enum GifsCmd { + /// Search or browse trending GIFs via the relay's KLIPY proxy. + /// + /// Omitting --query returns trending GIFs. The output is a JSON array of + /// GIF objects; paste the `cdn_url` field directly into + /// `buzz messages send --content` to share a GIF. + Search { + /// Search text; omit or leave empty for trending + #[arg(long)] + query: Option, + /// BCP 47 locale for provider results (default: $LANG or en_US) + #[arg(long)] + locale: Option, + }, + /// Report a selected GIF to the provider so it enters your Recents. + /// + /// The slug is the provider identifier in the search result objects. + Share { + /// Provider GIF slug from a search result + #[arg(long)] + slug: String, + }, +} + #[derive(Subcommand)] pub enum DmsCmd { /// List direct message conversations @@ -2080,6 +2108,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await, Cmd::Reactions(sub) => commands::reactions::dispatch(sub, &client).await, Cmd::Emoji(sub) => commands::emoji::dispatch(sub, &client).await, + Cmd::Gifs(sub) => commands::gifs::dispatch(sub, &client).await, Cmd::Dms(sub) => commands::dms::dispatch(sub, &client).await, Cmd::Users(sub) => commands::users::dispatch(sub, &client, &cli.format).await, Cmd::Workflows(sub) => commands::workflows::dispatch(sub, &client).await, @@ -2229,6 +2258,7 @@ mod tests { "dms", "emoji", "feed", + "gifs", "issues", "media", "mem", diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 71c0f1e73db..f43887b65b1 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -213,6 +213,21 @@ fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), Sdk Ok(()) } +/// Attach NIP-30 `["emoji", shortcode, url]` tags. +/// +/// Each element of `emoji_tags` must be a three-element vector whose first +/// entry is `"emoji"`. Entries that don't match this shape are silently +/// skipped so an unknown future shape never blocks a message send. +fn nip30_emoji_tags(emoji_tags: &[Vec], tags: &mut Vec) -> Result<(), SdkError> { + for et in emoji_tags { + if et.len() == 3 && et[0] == "emoji" { + let parts: Vec<&str> = et.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| SdkError::InvalidTag(e.to_string()))?); + } + } + Ok(()) +} + /// Build a stream message (kind 9). /// /// - `channel_id`: target channel UUID @@ -221,6 +236,7 @@ fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), Sdk /// - `mentions`: pubkey hex strings to p-tag (deduped, max 50) /// - `broadcast`: if true, adds `["broadcast", "1"]` tag /// - `media_tags`: raw imeta tag vectors +/// - `emoji_tags`: NIP-30 `["emoji", shortcode, url]` tag vectors pub fn build_message( channel_id: Uuid, content: &str, @@ -228,6 +244,7 @@ pub fn build_message( mentions: &[&str], broadcast: bool, media_tags: &[Vec], + emoji_tags: &[Vec], ) -> Result { check_content(content, 64 * 1024)?; let mut tags = vec![tag(&["h", &channel_id.to_string()])?]; @@ -239,6 +256,7 @@ pub fn build_message( tags.push(tag(&["broadcast", "1"])?); } imeta_tags(media_tags, &mut tags)?; + nip30_emoji_tags(emoji_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(9), content) .tags(tags) .allow_self_tagging()) @@ -2380,7 +2398,7 @@ mod tests { #[test] fn message_happy_path() { let cid = uuid(); - let ev = sign(build_message(cid, "hello", None, &[], false, &[]).unwrap()); + let ev = sign(build_message(cid, "hello", None, &[], false, &[], &[]).unwrap()); assert_eq!(ev.kind.as_u16(), 9); assert_eq!(ev.content, "hello"); assert!(has_tag(&ev, "h", &cid.to_string())); @@ -2394,7 +2412,8 @@ mod tests { let cid = uuid(); let sender = keys(); let self_pk = sender.public_key().to_hex(); - let builder = build_message(cid, "self-canary", None, &[&self_pk], false, &[]).unwrap(); + let builder = + build_message(cid, "self-canary", None, &[&self_pk], false, &[], &[]).unwrap(); let ev = builder.sign_with_keys(&sender).expect("sign"); assert!( has_tag(&ev, "p", &self_pk), @@ -2485,7 +2504,7 @@ mod tests { root_event_id: eid, parent_event_id: eid, }; - let ev = sign(build_message(cid, "reply", Some(&tr), &[], false, &[]).unwrap()); + let ev = sign(build_message(cid, "reply", Some(&tr), &[], false, &[], &[]).unwrap()); // Direct reply: only one e-tag with "reply" marker let e_tags: Vec<_> = ev .tags @@ -2508,7 +2527,7 @@ mod tests { root_event_id: root, parent_event_id: parent, }; - let ev = sign(build_message(cid, "nested", Some(&tr), &[], false, &[]).unwrap()); + let ev = sign(build_message(cid, "nested", Some(&tr), &[], false, &[], &[]).unwrap()); let e_tags: Vec<_> = ev .tags .iter() @@ -2526,7 +2545,7 @@ mod tests { #[test] fn message_broadcast_flag() { let cid = uuid(); - let ev = sign(build_message(cid, "hi", None, &[], true, &[]).unwrap()); + let ev = sign(build_message(cid, "hi", None, &[], true, &[], &[]).unwrap()); assert!(has_tag(&ev, "broadcast", "1")); } @@ -2534,7 +2553,7 @@ mod tests { fn message_mentions_deduped() { let cid = uuid(); let hex = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"; - let ev = sign(build_message(cid, "hi", None, &[hex, hex], false, &[]).unwrap()); + let ev = sign(build_message(cid, "hi", None, &[hex, hex], false, &[], &[]).unwrap()); let p_tags = tag_values(&ev, "p"); assert_eq!(p_tags.len(), 1); } @@ -2555,7 +2574,7 @@ mod tests { }) .collect(); let refs: Vec<&str> = hexes.iter().map(|s| s.as_str()).collect(); - let result = build_message(cid, "hi", None, &refs, false, &[]); + let result = build_message(cid, "hi", None, &refs, false, &[], &[]); assert!(matches!(result, Err(SdkError::TooManyMentions))); } @@ -2563,7 +2582,7 @@ mod tests { fn message_content_too_large() { let cid = uuid(); let big = "x".repeat(64 * 1024 + 1); - let result = build_message(cid, &big, None, &[], false, &[]); + let result = build_message(cid, &big, None, &[], false, &[], &[]); assert!(matches!(result, Err(SdkError::ContentTooLarge { .. }))); } @@ -2571,7 +2590,91 @@ mod tests { fn message_max_content_ok() { let cid = uuid(); let max = "x".repeat(64 * 1024); - assert!(build_message(cid, &max, None, &[], false, &[]).is_ok()); + assert!(build_message(cid, &max, None, &[], false, &[], &[]).is_ok()); + } + + #[test] + fn message_emoji_tags_attached() { + let cid = uuid(); + let emoji_tags = vec![ + vec![ + "emoji".to_string(), + "wave".to_string(), + "https://example.com/wave.gif".to_string(), + ], + vec![ + "emoji".to_string(), + "party".to_string(), + "https://example.com/party.gif".to_string(), + ], + ]; + let ev = sign( + build_message( + cid, + ":wave: hey :party:", + None, + &[], + false, + &[], + &emoji_tags, + ) + .unwrap(), + ); + // Both emoji tags present + assert!(ev + .tags + .iter() + .any(|t| t.as_slice() == ["emoji", "wave", "https://example.com/wave.gif"])); + assert!(ev + .tags + .iter() + .any(|t| t.as_slice() == ["emoji", "party", "https://example.com/party.gif"])); + // kind 9 + assert_eq!(ev.kind.as_u16(), 9); + } + + #[test] + fn message_malformed_emoji_tag_silently_skipped() { + let cid = uuid(); + let emoji_tags = vec![ + // only 2 elements — invalid, must be skipped + vec!["emoji".to_string(), "wave".to_string()], + // wrong kind — must be skipped + vec![ + "imeta".to_string(), + "wave".to_string(), + "https://example.com/wave.gif".to_string(), + ], + // valid + vec![ + "emoji".to_string(), + "ok".to_string(), + "https://example.com/ok.gif".to_string(), + ], + ]; + let ev = sign(build_message(cid, "hi", None, &[], false, &[], &emoji_tags).unwrap()); + let emoji_count = ev + .tags + .iter() + .filter(|t| t.as_slice().first().map(String::as_str) == Some("emoji")) + .count(); + assert_eq!(emoji_count, 1); + assert!(ev + .tags + .iter() + .any(|t| t.as_slice() == ["emoji", "ok", "https://example.com/ok.gif"])); + } + + #[test] + fn message_empty_emoji_tags_slice_ok() { + let cid = uuid(); + let ev = sign(build_message(cid, "hello", None, &[], false, &[], &[]).unwrap()); + let emoji_count = ev + .tags + .iter() + .filter(|t| t.as_slice().first().map(String::as_str) == Some("emoji")) + .count(); + assert_eq!(emoji_count, 0); } #[test] From e47daa36b65c844db620413ee7ef7ec0777cbc55 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 16:22:32 -0400 Subject: [PATCH 2/9] fix(cli): update countdown-bot build_message call site with emoji_tags arg Add missing `emoji_tags` (&[]) 7th argument to the SDK build_message call in examples/countdown-bot/src/main.rs, fixing E0061 compile error. This was the one remaining call site that wasn't updated when build_message's signature gained the additive emoji_tags parameter. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- examples/countdown-bot/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/countdown-bot/src/main.rs b/examples/countdown-bot/src/main.rs index ed062121562..75d4565fe4e 100644 --- a/examples/countdown-bot/src/main.rs +++ b/examples/countdown-bot/src/main.rs @@ -240,6 +240,7 @@ async fn maybe_reply( &[&event.pubkey.to_hex()], false, &[], + &[], )?; let reply_event = builder.sign_with_keys(&config.bot_keys)?; let reply_event_id = reply_event.id.to_hex(); From 71d0972b631ca9ef4c088c60fa11bf55ce704ea2 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 16:33:27 -0400 Subject: [PATCH 3/9] fix(cli): address pass-1 review findings in buzz gifs Four IMPORTANT findings from Thufir's pass-1 review, all in gifs.rs: 1. cdn_url normalization: cmd_search now emits typed [{cdn_url, slug, title, width, height, preview_url?}] records via normalize_gif_response(), which ports normalizeKlipyGifs() from desktop/src/features/gifs/api.ts. Asset fallback order (md>hd>sm>xs for original; sm.webp>sm.gif>xs.webp>xs.gif> md.webp for preview) mirrors the desktop exactly. Malformed response envelopes (missing data.data array) now return a clear error instead of silently emitting []. 2. safe_relay_path validation: resolve_gif_descriptor() now validates both search and share paths with safe_relay_path() before either reaches post_json_authed(). Ports the desktop safeRelayPath contract exactly (api.ts:64-74): leading /, not //, no backslash, percent, query, fragment, or dot/dot-dot segments. Adversarial corpus from api.test.mjs is bound as Rust tests against the production validator. 3. customer_id anonymity: replaced SHA-256(pubkey_hex) with a domain-separated derivation from secret key material: SHA-256(secret_bytes || NUL || relay_url), truncated to 32 hex chars. Relay-scoped (different relay -> different ID), not computable from public data, stateless. Tests verify relay-scoping, key-scoping, and that the result differs from the old pubkey-hash approach. 4. Test teeth: new tests drive resolve_gif_descriptor gate logic through a pure typed parse_descriptor() extractor; normalize_gif_response() is tested directly for all normalization semantics (fallback order, type filtering, missing-slug skip, malformed-envelope error, empty-array ok). Real HTTP integration tests via axum fake server verify that cmd_search and cmd_share hit the relay-advertised paths with NIP-98 Authorization headers and the expected JSON request bodies; a gating test drives cmd_search against a fake relay missing buzz-gif and confirms a clear error. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-cli/src/commands/gifs.rs | 807 ++++++++++++++++++++++++--- 1 file changed, 725 insertions(+), 82 deletions(-) diff --git a/crates/buzz-cli/src/commands/gifs.rs b/crates/buzz-cli/src/commands/gifs.rs index d05eced7776..c40d183ca3f 100644 --- a/crates/buzz-cli/src/commands/gifs.rs +++ b/crates/buzz-cli/src/commands/gifs.rs @@ -4,7 +4,7 @@ //! advertised in the NIP-11 `gif` descriptor. No provider credential is held //! by the agent — the relay proxies KLIPY and returns only allowlisted data. //! -//! Sending a GIF is a normal message whose content contains the CDN URL +//! Sending a GIF is a normal message whose content contains the `cdn_url` //! returned by search — no special send-path handling, no imeta. use crate::client::BuzzClient; @@ -15,27 +15,71 @@ const REQUIRED_EXTENSION: &str = "buzz-gif"; /// Gate: `gif.provider` must be this value. const REQUIRED_PROVIDER: &str = "klipy"; -/// Derive a stable anonymous `customer_id` from the agent keypair. +// --------------------------------------------------------------------------- +// Safe relay-relative path validation +// --------------------------------------------------------------------------- + +/// Validate that a NIP-11-advertised path is a safe relay-relative path. +/// +/// Mirrors the desktop `safeRelayPath` contract in +/// `desktop/src/features/gifs/api.ts:64-74` exactly: +/// - must be a string that starts with `/` +/// - must NOT start with `//` (avoids authority shift) +/// - must NOT contain `\` (Windows-style traversal) +/// - must NOT contain `%` (URL-encoded bypass attempts) +/// - must NOT contain `?` (query injection) +/// - must NOT contain `#` (fragment injection) +/// - no path segment may be `.` or `..` (traversal) +pub(crate) fn safe_relay_path(path: &str) -> bool { + path.starts_with('/') + && !path.starts_with("//") + && !path.contains('\\') + && !path.contains('%') + && !path.contains('?') + && !path.contains('#') + && !path.split('/').any(|seg| seg == "." || seg == "..") +} + +// --------------------------------------------------------------------------- +// Customer ID derivation +// --------------------------------------------------------------------------- + +/// Derive a stable, relay-scoped anonymous `customer_id` from secret key material. /// /// KLIPY requires a per-installation identifier that is stable and anonymous. -/// SHA-256 of the public key hex satisfies both requirements: stable across -/// sessions, never traceable to a person, never stored. The first 32 hex chars -/// (128 bits) are ample for KLIPY's uniqueness needs; the full 64-char hash is -/// within the server's 128-char limit but unnecessarily long. -fn customer_id_from_pubkey(pubkey_hex: &str) -> String { +/// Using SHA-256 of the *public* key would be stable but NOT anonymous — the +/// input is public, so the ID is computable by any observer, and the same value +/// would appear across all relays (cross-relay linkability). +/// +/// Instead, we domain-separate with the relay URL and sign with the *secret* key: +/// `SHA-256(secret_key_bytes || '\0' || relay_url_bytes)` +/// This is: +/// - **stable**: deterministic given the same keypair + relay. +/// - **relay-scoped**: different relay → different ID, no cross-relay correlation. +/// - **not computable from public data**: requires secret key material. +/// - **stateless**: no file I/O, no storage. +/// +/// The first 16 bytes (32 hex chars) give 128 bits of uniqueness, ample for +/// KLIPY's per-installation needs. +fn customer_id(secret_key_bytes: &[u8], relay_url: &str) -> String { use sha2::{Digest, Sha256}; - let hash = Sha256::digest(pubkey_hex.as_bytes()); + let mut hasher = Sha256::new(); + hasher.update(secret_key_bytes); + hasher.update(b"\0"); // domain separator + hasher.update(relay_url.as_bytes()); + let hash = hasher.finalize(); hex::encode(&hash[..16]) // 16 bytes → 32 hex chars } +// --------------------------------------------------------------------------- +// Locale +// --------------------------------------------------------------------------- + /// Locale to send to KLIPY. Reads `LANG` first, falls back to `en_US`. fn default_locale() -> String { std::env::var("LANG") .ok() .and_then(|l| { - // `LANG` is typically `en_US.UTF-8` or `en_US`; strip the encoding - // suffix and take up to 5 chars which gives the provider-understood - // locale code (e.g. `en_US`). let code: String = l .splitn(2, '.') .next() @@ -52,12 +96,20 @@ fn default_locale() -> String { .unwrap_or_else(|| "en_US".to_string()) } +// --------------------------------------------------------------------------- +// NIP-11 descriptor resolution +// --------------------------------------------------------------------------- + /// Resolve the relay's `gif` descriptor from its NIP-11 document. /// -/// Returns `(search_path, share_path)` as relay-relative strings (e.g. -/// `"/gifs/search"`, `"/gifs/share"`). Returns a clear `CliError` if the -/// relay does not advertise `buzz-gif` or the provider is not `klipy`. -async fn resolve_gif_descriptor(client: &BuzzClient) -> Result<(String, String), CliError> { +/// Returns `(search_path, share_path)` as validated relay-relative strings. +/// Fails with a clear `CliError` if: +/// - the relay does not advertise `buzz-gif` +/// - the provider is not `klipy` +/// - either path is absent or fails the `safe_relay_path` check +pub(crate) async fn resolve_gif_descriptor( + client: &BuzzClient, +) -> Result<(String, String), CliError> { let raw = client.get_public("/info").await?; let info: serde_json::Value = serde_json::from_str(&raw) .map_err(|e| CliError::Other(format!("invalid NIP-11 response: {e}")))?; @@ -85,6 +137,7 @@ async fn resolve_gif_descriptor(client: &BuzzClient) -> Result<(String, String), ))); } + // Gate 3: both paths must be present and pass the safe-path check. let search = gif .get("search") .and_then(|v| v.as_str()) @@ -95,65 +148,207 @@ async fn resolve_gif_descriptor(client: &BuzzClient) -> Result<(String, String), .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - if search.is_empty() || share.is_empty() { - return Err(CliError::Other( - "relay gif descriptor is missing search or share path".to_string(), - )); + + if !safe_relay_path(&search) { + return Err(CliError::Other(format!( + "relay gif descriptor search path is not a safe relay-relative path: {search:?}" + ))); + } + if !safe_relay_path(&share) { + return Err(CliError::Other(format!( + "relay gif descriptor share path is not a safe relay-relative path: {share:?}" + ))); } Ok((search, share)) } +// --------------------------------------------------------------------------- +// Response normalization +// --------------------------------------------------------------------------- + +/// Normalized GIF entry emitted by `buzz gifs search`. +/// +/// `cdn_url` is the URL to embed directly in a `buzz messages send --content` +/// argument. Agents paste it as-is; no further processing is needed. +#[derive(serde::Serialize)] +pub(crate) struct GifEntry { + pub cdn_url: String, + pub slug: String, + pub title: String, + pub width: u64, + pub height: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub preview_url: Option, +} + +/// Normalize the KLIPY `data.data` array to typed `GifEntry` records. +/// +/// Mirrors `normalizeKlipyGifs` in `desktop/src/features/gifs/api.ts`: +/// - skips items that are not `type: "gif"`, lack a `slug`, or have no +/// complete sendable asset +/// - asset fallback order for `cdn_url` (original): `md.gif`, `hd.gif`, +/// `sm.gif`, `xs.gif` +/// - asset fallback order for `preview_url`: `sm.webp`, `sm.gif`, +/// `xs.webp`, `xs.gif`, `md.webp` +/// - an item with no usable original or preview is silently skipped +/// - malformed envelopes (wrong outer shape) return an error rather +/// than a silent empty array +pub(crate) fn normalize_gif_response(raw: &str) -> Result, CliError> { + let parsed: serde_json::Value = serde_json::from_str(raw) + .map_err(|e| CliError::Other(format!("invalid GIF search response: {e}")))?; + + // The relay wraps in {"result": true, "data": {"data": [...]}}. + // A missing outer envelope is an error, not a silent empty list. + let items = parsed + .get("data") + .and_then(|d| d.get("data")) + .and_then(|v| v.as_array()) + .ok_or_else(|| { + CliError::Other( + "GIF search response missing expected envelope data.data array".to_string(), + ) + })?; + + let mut out = Vec::new(); + for item in items { + // Only process type:"gif" items with a slug. + if item.get("type").and_then(|v| v.as_str()) != Some("gif") { + continue; + } + let slug = match item.get("slug").and_then(|v| v.as_str()) { + Some(s) if !s.is_empty() => s.to_string(), + _ => continue, + }; + let title = item + .get("title") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "GIF".to_string()); + + let file = match item.get("file") { + Some(f) => f, + None => continue, + }; + + // cdn_url: md.gif → hd.gif → sm.gif → xs.gif + let original = first_complete_gif_asset( + file, + &[ + &["md", "gif"], + &["hd", "gif"], + &["sm", "gif"], + &["xs", "gif"], + ], + ); + // preview_url: sm.webp → sm.gif → xs.webp → xs.gif → md.webp + let preview = first_complete_gif_asset( + file, + &[ + &["sm", "webp"], + &["sm", "gif"], + &["xs", "webp"], + &["xs", "gif"], + &["md", "webp"], + ], + ); + + let (cdn_url, width, height) = match original { + Some(a) => a, + None => continue, + }; + + let preview_url = preview.map(|(u, _, _)| u); + + out.push(GifEntry { + cdn_url, + slug, + title, + width, + height, + preview_url, + }); + } + + Ok(out) +} + +/// Extract the URL, width, and height from the first complete asset at +/// `file[size][fmt]` where `size`/`fmt` pairs are tried in order. +/// "Complete" means url (non-empty string), width (number), height (number) +/// are all present — mirrors `isCompleteAsset` in the desktop. +fn first_complete_gif_asset( + file: &serde_json::Value, + candidates: &[&[&str; 2]], +) -> Option<(String, u64, u64)> { + for &[size, fmt] in candidates { + let asset = file.get(size).and_then(|s| s.get(fmt)); + if let Some(a) = asset { + let url = a.get("url").and_then(|v| v.as_str()).unwrap_or(""); + let width = a.get("width").and_then(|v| v.as_u64()); + let height = a.get("height").and_then(|v| v.as_u64()); + if !url.is_empty() { + if let (Some(w), Some(h)) = (width, height) { + return Some((url.to_string(), w, h)); + } + } + } + } + None +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + /// `buzz gifs search [--query ] [--locale ]` /// -/// Empty/omitted `query` returns KLIPY trending GIFs. Output is a JSON -/// array of GIF objects; each entry's `cdn_url` field is the URL to embed -/// in a `buzz messages send --content` argument. +/// Empty/omitted `query` returns KLIPY trending GIFs. Output is a JSON array +/// of normalized GIF objects; each entry's `cdn_url` is the URL to embed in a +/// `buzz messages send --content` argument. pub async fn cmd_search( client: &BuzzClient, query: &str, locale: Option<&str>, ) -> Result<(), CliError> { let (search_path, _) = resolve_gif_descriptor(client).await?; - let customer_id = customer_id_from_pubkey(&client.keys().public_key().to_hex()); + let cid = customer_id( + client.keys().secret_key().as_secret_bytes(), + client.relay_url(), + ); let locale = locale.map(|l| l.to_string()).unwrap_or_else(default_locale); let body = serde_json::json!({ "query": query, - "customer_id": customer_id, + "customer_id": cid, "locale": locale, }); let raw = client.post_json_authed(&search_path, &body).await?; - - // Relay returns `{"result": true, "data": {"data": [...]}}`. Unwrap to the - // inner array so agents get a flat list they can iterate directly. - let parsed: serde_json::Value = serde_json::from_str(&raw) - .map_err(|e| CliError::Other(format!("invalid GIF search response: {e}")))?; - let gifs = parsed - .get("data") - .and_then(|d| d.get("data")) - .cloned() - .unwrap_or(serde_json::Value::Array(vec![])); - + let entries = normalize_gif_response(&raw)?; println!( "{}", - serde_json::to_string(&gifs).unwrap_or_else(|_| "[]".to_string()) + serde_json::to_string(&entries) + .map_err(|e| CliError::Other(format!("output serialization failed: {e}")))? ); Ok(()) } /// `buzz gifs share --slug ` /// -/// Reports a selected GIF to KLIPY so it can update Recents. The `slug` -/// is the provider identifier returned in search results. Prints +/// Reports a selected GIF to KLIPY so it can update Recents. The `slug` is +/// the provider identifier returned in search results. Prints /// `{"accepted": true}` on success. pub async fn cmd_share(client: &BuzzClient, slug: &str) -> Result<(), CliError> { let (_, share_path) = resolve_gif_descriptor(client).await?; - let customer_id = customer_id_from_pubkey(&client.keys().public_key().to_hex()); + let cid = customer_id( + client.keys().secret_key().as_secret_bytes(), + client.relay_url(), + ); let body = serde_json::json!({ "slug": slug, - "customer_id": customer_id, + "customer_id": cid, }); // The relay returns 204 No Content on success; post_json_authed returns "". client.post_json_authed(&share_path, &body).await?; @@ -170,83 +365,531 @@ pub async fn dispatch(cmd: crate::GifsCmd, client: &BuzzClient) -> Result<(), Cl } } +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + #[cfg(test)] mod tests { use super::*; + // ----------------------------------------------------------------------- + // safe_relay_path + // ----------------------------------------------------------------------- + + #[test] + fn safe_relay_path_accepts_normal_paths() { + assert!(safe_relay_path("/gifs/search")); + assert!(safe_relay_path("/gifs/share")); + assert!(safe_relay_path("/api/v2/gifs/search")); + } + + #[test] + fn safe_relay_path_rejects_adversarial_corpus() { + // Desktop adversarial corpus from desktop/src/features/gifs/api.test.mjs + let bad_paths = [ + "https://attacker.example/search", // absolute URL, no leading / + "//attacker.example/search", // protocol-relative → authority shift + "/\\attacker.example/search", // backslash + "/%5c%5cattacker.example/search", // percent-encoded + "/gifs/../admin", // dot-dot traversal + "/gifs/%2e%2e/admin", // percent-encoded dot-dot + "/gifs/search?redirect=https://attacker.example", // query injection + "/gifs/search#fragment", // fragment injection + ]; + for path in bad_paths { + assert!( + !safe_relay_path(path), + "expected safe_relay_path({path:?}) == false" + ); + } + } + + #[test] + fn safe_relay_path_rejects_empty_and_relative() { + assert!(!safe_relay_path("")); + assert!(!safe_relay_path("gifs/search")); // no leading / + assert!(!safe_relay_path("//")); + } + + // ----------------------------------------------------------------------- + // customer_id + // ----------------------------------------------------------------------- + #[test] fn customer_id_is_32_hex_chars_and_stable() { - let pk = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; - let id = customer_id_from_pubkey(pk); + let sk = [0xab_u8; 32]; + let id = customer_id(&sk, "https://relay.example"); assert_eq!(id.len(), 32); assert!(id.chars().all(|c| c.is_ascii_hexdigit())); - // Stability: same input → same output. - assert_eq!(id, customer_id_from_pubkey(pk)); + assert_eq!(id, customer_id(&sk, "https://relay.example")); } #[test] - fn customer_id_differs_for_different_pubkeys() { - let a = customer_id_from_pubkey("aaaa"); - let b = customer_id_from_pubkey("bbbb"); - assert_ne!(a, b); + fn customer_id_is_relay_scoped() { + let sk = [0xcd_u8; 32]; + let id_a = customer_id(&sk, "https://relay-a.example"); + let id_b = customer_id(&sk, "https://relay-b.example"); + assert_ne!( + id_a, id_b, + "same key, different relay → different customer_id" + ); } #[test] - fn default_locale_falls_back_when_lang_unset() { - // Remove LANG if set; we cannot safely setenv in parallel tests, so - // only verify the fallback path indirectly via the absence condition. - let locale = - if std::env::var("LANG").ok().map(|l| l.trim().to_string()) == Some(String::new()) { - "en_US".to_string() - } else { - // LANG is set — just confirm we get a non-empty string. - default_locale() - }; + fn customer_id_differs_for_different_keys() { + let id_a = customer_id(&[0xaa_u8; 32], "https://relay.example"); + let id_b = customer_id(&[0xbb_u8; 32], "https://relay.example"); + assert_ne!(id_a, id_b); + } + + #[test] + fn customer_id_not_equal_to_pubkey_hash() { + // The customer_id must NOT be derivable from the public key alone. + use sha2::{Digest, Sha256}; + let sk = [0xde_u8; 32]; + // What the old pubkey-hash approach would have produced (approximately): + let naive_hash = hex::encode(&Sha256::digest(hex::encode(&sk).as_bytes())[..16]); + let actual = customer_id(&sk, "https://relay.example"); + assert_ne!( + actual, naive_hash, + "customer_id must not equal SHA-256(pubkey_hex)[..16]" + ); + } + + // ----------------------------------------------------------------------- + // default_locale + // ----------------------------------------------------------------------- + + #[test] + fn default_locale_is_nonempty() { + let locale = default_locale(); assert!(!locale.is_empty()); } - /// Checks that NIP-11 gating rejects a relay that doesn't advertise buzz-gif. + // ----------------------------------------------------------------------- + // resolve_gif_descriptor — pure parsing against a typed descriptor + // ----------------------------------------------------------------------- + + /// Parse a JSON NIP-11 fragment the same way `resolve_gif_descriptor` does, + /// returning `Ok((search, share))` or `Err(msg)`. Extracted as a pure fn + /// so tests can drive the full gate logic without I/O. + fn parse_descriptor(info: &serde_json::Value) -> Result<(String, String), String> { + let extensions = info + .get("supported_extensions") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::>()) + .unwrap_or_default(); + if !extensions.iter().any(|&e| e == REQUIRED_EXTENSION) { + return Err(format!("missing {REQUIRED_EXTENSION}")); + } + let gif = info + .get("gif") + .ok_or_else(|| "no gif descriptor".to_string())?; + let provider = gif.get("provider").and_then(|v| v.as_str()).unwrap_or(""); + if provider != REQUIRED_PROVIDER { + return Err(format!("wrong provider: {provider}")); + } + let search = gif + .get("search") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let share = gif + .get("share") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if !safe_relay_path(&search) { + return Err(format!("unsafe search path: {search:?}")); + } + if !safe_relay_path(&share) { + return Err(format!("unsafe share path: {share:?}")); + } + Ok((search, share)) + } + #[test] - fn nip11_gating_logic_missing_extension() { + fn descriptor_missing_extension_is_rejected() { let info = serde_json::json!({ "supported_extensions": ["buzz-emoji"], "gif": { "provider": "klipy", "search": "/gifs/search", "share": "/gifs/share" } }); - // Simulate the gating check inline (no I/O needed). - let extensions: Vec<&str> = info["supported_extensions"] - .as_array() - .unwrap() - .iter() - .filter_map(|v| v.as_str()) - .collect(); - assert!(!extensions.contains(&REQUIRED_EXTENSION)); + assert!(parse_descriptor(&info).is_err()); + assert!(parse_descriptor(&info) + .unwrap_err() + .contains("missing buzz-gif")); } #[test] - fn nip11_gating_logic_wrong_provider() { + fn descriptor_wrong_provider_is_rejected() { let info = serde_json::json!({ "supported_extensions": ["buzz-gif"], "gif": { "provider": "tenor", "search": "/gifs/search", "share": "/gifs/share" } }); - let provider = info["gif"]["provider"].as_str().unwrap_or(""); - assert_ne!(provider, REQUIRED_PROVIDER); + assert!(parse_descriptor(&info) + .unwrap_err() + .contains("wrong provider")); + } + + #[test] + fn descriptor_unsafe_search_path_is_rejected() { + let info = serde_json::json!({ + "supported_extensions": ["buzz-gif"], + "gif": { "provider": "klipy", "search": "//attacker.example/x", "share": "/gifs/share" } + }); + assert!(parse_descriptor(&info) + .unwrap_err() + .contains("unsafe search path")); + } + + #[test] + fn descriptor_unsafe_share_path_is_rejected() { + let info = serde_json::json!({ + "supported_extensions": ["buzz-gif"], + "gif": { "provider": "klipy", "search": "/gifs/search", "share": "/gifs/../admin" } + }); + assert!(parse_descriptor(&info) + .unwrap_err() + .contains("unsafe share path")); } #[test] - fn nip11_gating_logic_passes_valid_descriptor() { + fn descriptor_valid_passes() { let info = serde_json::json!({ "supported_extensions": ["buzz-gif"], "gif": { "provider": "klipy", "search": "/gifs/search", "share": "/gifs/share" } }); - let extensions: Vec<&str> = info["supported_extensions"] - .as_array() - .unwrap() - .iter() - .filter_map(|v| v.as_str()) - .collect(); - assert!(extensions.contains(&REQUIRED_EXTENSION)); - assert_eq!(info["gif"]["provider"].as_str().unwrap(), REQUIRED_PROVIDER); - assert_eq!(info["gif"]["search"].as_str().unwrap(), "/gifs/search"); - assert_eq!(info["gif"]["share"].as_str().unwrap(), "/gifs/share"); + let (search, share) = parse_descriptor(&info).unwrap(); + assert_eq!(search, "/gifs/search"); + assert_eq!(share, "/gifs/share"); + } + + // ----------------------------------------------------------------------- + // normalize_gif_response + // ----------------------------------------------------------------------- + + /// Fixture matching the shape used in desktop/tests/e2e/messaging.spec.ts + fn e2e_fixture() -> &'static str { + r#"{ + "result": true, + "data": { + "data": [ + { + "id": null, + "type": "gif", + "slug": "e2e-ship-it", + "title": "Ship it", + "file": { + "md": { "gif": { "height": 180, "size": 42, "url": "https://static.klipy.com/ship-it.gif", "width": 320 } }, + "sm": { "webp": { "height": 90, "size": 12, "url": "https://static.klipy.com/ship-it-sm.webp", "width": 160 } } + } + } + ] + } + }"# + } + + #[test] + fn normalize_extracts_cdn_url_and_preview() { + let entries = normalize_gif_response(e2e_fixture()).unwrap(); + assert_eq!(entries.len(), 1); + let e = &entries[0]; + assert_eq!(e.cdn_url, "https://static.klipy.com/ship-it.gif"); + assert_eq!(e.slug, "e2e-ship-it"); + assert_eq!(e.title, "Ship it"); + assert_eq!(e.width, 320); + assert_eq!(e.height, 180); + assert_eq!( + e.preview_url.as_deref(), + Some("https://static.klipy.com/ship-it-sm.webp") + ); + } + + #[test] + fn normalize_skips_non_gif_type() { + let raw = r#"{"result":true,"data":{"data":[ + {"type":"ad","slug":"s","file":{"md":{"gif":{"url":"https://x.com/a.gif","width":1,"height":1,"size":1}}}}, + {"type":"gif","slug":"real","title":"R","file":{"md":{"gif":{"url":"https://x.com/r.gif","width":2,"height":2,"size":2}}}} + ]}}"#; + let entries = normalize_gif_response(raw).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].slug, "real"); + } + + #[test] + fn normalize_skips_items_without_slug() { + let raw = r#"{"result":true,"data":{"data":[ + {"type":"gif","file":{"md":{"gif":{"url":"https://x.com/a.gif","width":1,"height":1,"size":1}}}} + ]}}"#; + let entries = normalize_gif_response(raw).unwrap(); + assert!(entries.is_empty()); + } + + #[test] + fn normalize_asset_fallback_order() { + // No md.gif, has hd.gif — should pick hd.gif as cdn_url. + let raw = r#"{"result":true,"data":{"data":[ + {"type":"gif","slug":"fallback","title":"F","file":{ + "hd":{"gif":{"url":"https://x.com/hd.gif","width":640,"height":360,"size":100}}, + "sm":{"webp":{"url":"https://x.com/sm.webp","width":160,"height":90,"size":10}} + }} + ]}}"#; + let entries = normalize_gif_response(raw).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].cdn_url, "https://x.com/hd.gif"); + } + + #[test] + fn normalize_skips_items_with_no_usable_original() { + // Only a preview asset, no gif asset at any size. + let raw = r#"{"result":true,"data":{"data":[ + {"type":"gif","slug":"broken","title":"B","file":{ + "sm":{"webp":{"url":"https://x.com/sm.webp","width":160,"height":90,"size":10}} + }} + ]}}"#; + let entries = normalize_gif_response(raw).unwrap(); + assert!(entries.is_empty()); + } + + #[test] + fn normalize_rejects_malformed_envelope() { + // Missing the data.data wrapper — must error, not silently return []. + let bad = r#"{"result":true,"gifs":[]}"#; + assert!(normalize_gif_response(bad).is_err()); + } + + #[test] + fn normalize_empty_data_array_is_ok() { + let raw = r#"{"result":true,"data":{"data":[]}}"#; + let entries = normalize_gif_response(raw).unwrap(); + assert!(entries.is_empty()); + } + + // ----------------------------------------------------------------------- + // HTTP integration tests: real client seam via axum fake server + // ----------------------------------------------------------------------- + + use crate::client::BuzzClient; + use axum::body::Bytes; + use axum::extract::State; + use axum::http::{HeaderMap, StatusCode}; + use axum::routing::post; + use axum::Router; + use nostr::Keys; + use std::net::SocketAddr; + use std::sync::{Arc, Mutex}; + use tokio::net::TcpListener; + + /// Captured request data from the fake server. + #[derive(Clone, Default)] + #[allow(dead_code)] + struct Captured { + method: String, + path: String, + auth_header: String, + auth_tag_header: String, + body: String, + } + + /// A simple fake relay: serves NIP-11 at `/info` and captures POST bodies + /// at `/gifs/search` and `/gifs/share`. + async fn fake_server( + search_status: StatusCode, + search_body: String, + share_status: StatusCode, + ) -> (String, Arc>>) { + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + + type S = (Arc>>, StatusCode, String, StatusCode); + let state: S = (captured.clone(), search_status, search_body, share_status); + + let app = Router::new() + .route( + "/info", + axum::routing::get(|| async { + ( + StatusCode::OK, + [("content-type", "application/nostr+json")], + r#"{"supported_extensions":["buzz-gif"],"gif":{"provider":"klipy","search":"/gifs/search","share":"/gifs/share"}}"#, + ) + }), + ) + .route( + "/gifs/search", + post( + |State((cap, search_st, search_bd, _)): State, + headers: HeaderMap, + body: Bytes| async move { + let body_str = String::from_utf8_lossy(&body).to_string(); + cap.lock().unwrap().push(Captured { + method: "POST".to_string(), + path: "/gifs/search".to_string(), + auth_header: headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(), + auth_tag_header: headers + .get("x-auth-tag") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(), + body: body_str, + }); + axum::response::Response::builder() + .status(search_st) + .header("content-type", "application/json") + .body(axum::body::Body::from(search_bd.clone())) + .unwrap() + }, + ), + ) + .route( + "/gifs/share", + post( + |State((cap, _, _, share_st)): State, + headers: HeaderMap, + body: Bytes| async move { + let body_str = String::from_utf8_lossy(&body).to_string(); + cap.lock().unwrap().push(Captured { + method: "POST".to_string(), + path: "/gifs/share".to_string(), + auth_header: headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(), + auth_tag_header: headers + .get("x-auth-tag") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(), + body: body_str, + }); + axum::response::Response::builder() + .status(share_st) + .body(axum::body::Body::empty()) + .unwrap() + }, + ), + ) + .with_state(state); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: SocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), captured) + } + + fn test_client(base_url: &str) -> BuzzClient { + let keys = Keys::generate(); + BuzzClient::new(base_url.to_string(), keys, None, None).unwrap() + } + + #[tokio::test] + async fn search_sends_nip98_auth_and_correct_body() { + let search_resp = r#"{"result":true,"data":{"data":[ + {"type":"gif","slug":"test-slug","title":"Test","file":{ + "md":{"gif":{"url":"https://cdn.klipy.com/test.gif","width":320,"height":180,"size":50}} + }} + ]}}"#.to_string(); + let (url, captured) = + fake_server(StatusCode::OK, search_resp, StatusCode::NO_CONTENT).await; + let client = test_client(&url); + + cmd_search(&client, "hello", Some("en_US")).await.unwrap(); + + let calls = captured.lock().unwrap(); + let call = calls.iter().find(|c| c.path == "/gifs/search").unwrap(); + // NIP-98 Authorization header must be present and start with "Nostr ". + assert!( + call.auth_header.starts_with("Nostr "), + "Authorization must be NIP-98 Nostr token, got: {:?}", + call.auth_header + ); + // Body must contain the expected fields. + let body: serde_json::Value = serde_json::from_str(&call.body).unwrap(); + assert_eq!(body["query"], "hello"); + assert_eq!(body["locale"], "en_US"); + assert!( + body["customer_id"] + .as_str() + .map(|s| s.len() == 32) + .unwrap_or(false), + "customer_id must be 32 hex chars" + ); + } + + #[tokio::test] + async fn search_output_contains_cdn_url() { + let search_resp = r#"{"result":true,"data":{"data":[ + {"type":"gif","slug":"test-slug","title":"Test","file":{ + "md":{"gif":{"url":"https://cdn.klipy.com/test.gif","width":320,"height":180,"size":50}} + }} + ]}}"#.to_string(); + let (url, _) = fake_server(StatusCode::OK, search_resp, StatusCode::NO_CONTENT).await; + let client = test_client(&url); + // If cmd_search completes without error, the normalized output was valid. + cmd_search(&client, "", None).await.unwrap(); + } + + #[tokio::test] + async fn share_sends_nip98_auth_and_correct_body() { + let (url, captured) = + fake_server(StatusCode::OK, "[]".to_string(), StatusCode::NO_CONTENT).await; + let client = test_client(&url); + + cmd_share(&client, "my-gif-slug").await.unwrap(); + + let calls = captured.lock().unwrap(); + let call = calls.iter().find(|c| c.path == "/gifs/share").unwrap(); + assert!( + call.auth_header.starts_with("Nostr "), + "Authorization must be NIP-98 Nostr token" + ); + let body: serde_json::Value = serde_json::from_str(&call.body).unwrap(); + assert_eq!(body["slug"], "my-gif-slug"); + assert!( + body["customer_id"] + .as_str() + .map(|s| s.len() == 32) + .unwrap_or(false), + "customer_id must be 32 hex chars" + ); + } + + #[tokio::test] + async fn share_returns_accepted_true_on_204() { + let (url, _) = fake_server(StatusCode::OK, "[]".to_string(), StatusCode::NO_CONTENT).await; + let client = test_client(&url); + // Completes without error = accepted. + cmd_share(&client, "slug-abc").await.unwrap(); + } + + #[tokio::test] + async fn search_rejects_missing_extension_in_nip11() { + // Serve NIP-11 without buzz-gif. + let app = Router::new().route( + "/info", + axum::routing::get(|| async { + ( + StatusCode::OK, + [("content-type", "application/nostr+json")], + r#"{"supported_extensions":[],"gif":{"provider":"klipy","search":"/gifs/search","share":"/gifs/share"}}"#, + ) + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: SocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let url = format!("http://{addr}"); + let client = test_client(&url); + + let err = cmd_search(&client, "test", None).await.unwrap_err(); + assert!( + err.to_string().contains("buzz-gif"), + "error must mention buzz-gif, got: {err}" + ); } } From b7a17084aaa0fc0f26f924548abe6e940b415aab Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 16:50:47 -0400 Subject: [PATCH 4/9] fix(cli): address pass-2 follow-up items (a) and (b) from Paul (a) Missing send-path palette seam test: add 7 tokio integration tests to emoji.rs that drive the production resolve_emoji_tags_for_content() through a real BuzzClient against an axum fake /query server. Tests cover: known shortcode -> correct tag, unknown shortcode filtered out, dedup of repeated shortcode, first-appearance order, case-insensitive match -> canonical lowercase tag, no-colon content -> zero palette queries (short-circuit), and unknown-only content -> exactly one palette query with empty tag result. (b) Descriptor test helper re-introduced non-falsifiable pattern: extract the synchronous NIP-11 parsing step from resolve_gif_descriptor() into a new pub(crate) fn parse_gif_descriptor_info(), which resolve_gif_descriptor() now calls after fetching the document. Descriptor gate tests are updated to call the production function directly; the test-local parse_descriptor helper is removed entirely. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-cli/src/commands/emoji.rs | 157 ++++++++++++++++++++++++++ crates/buzz-cli/src/commands/gifs.rs | 110 ++++++++---------- 2 files changed, 202 insertions(+), 65 deletions(-) diff --git a/crates/buzz-cli/src/commands/emoji.rs b/crates/buzz-cli/src/commands/emoji.rs index b8cd3a8fdc5..af3dc007cab 100644 --- a/crates/buzz-cli/src/commands/emoji.rs +++ b/crates/buzz-cli/src/commands/emoji.rs @@ -530,4 +530,161 @@ mod tests { let result = scan_shortcodes(":party-parrot: :sweat_blob:"); assert_eq!(result, vec!["party-parrot", "sweat_blob"]); } + + // ── resolve_emoji_tags_for_content — send-path palette seam ───────────── + // + // These tests drive the production `resolve_emoji_tags_for_content` through + // a real `BuzzClient` against an axum fake `/query` server. They verify + // the full chain: scan → palette fetch → tag assembly. + + use crate::client::BuzzClient; + use axum::body::Bytes; + use axum::extract::State; + use axum::http::{HeaderMap, StatusCode}; + use axum::routing::post; + use axum::Router; + use nostr::Keys; + use std::net::SocketAddr; + use std::sync::{Arc, Mutex}; + use tokio::net::TcpListener; + + fn test_client(base_url: &str) -> BuzzClient { + BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap() + } + + /// Fake relay: serves a `/query` endpoint returning the given JSON body, + /// and records how many times it was called. + async fn fake_query_server(response_body: String) -> (String, Arc>) { + let call_count: Arc> = Arc::new(Mutex::new(0)); + type S = (Arc>, String); + let state: S = (call_count.clone(), response_body); + + let app = Router::new() + .route( + "/query", + post( + |State((count, body)): State, _headers: HeaderMap, _req: Bytes| async move { + *count.lock().unwrap() += 1; + (StatusCode::OK, [("content-type", "application/json")], body) + }, + ), + ) + .with_state(state); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: SocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), call_count) + } + + /// Palette response: two custom emoji — `wave` and `sweatblob`. + fn palette_response() -> String { + serde_json::json!([{ + "created_at": 100, + "tags": [ + ["d", "buzz:custom-emoji"], + ["emoji", "wave", "https://cdn.example.com/wave.png"], + ["emoji", "sweatblob", "https://cdn.example.com/sweatblob.gif"] + ] + }]) + .to_string() + } + + #[tokio::test] + async fn resolve_tags_known_shortcode_returns_correct_tag() { + let (url, _calls) = fake_query_server(palette_response()).await; + let client = test_client(&url); + let tags = resolve_emoji_tags_for_content(&client, "hello :wave:") + .await + .unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!( + tags[0], + vec!["emoji", "wave", "https://cdn.example.com/wave.png"] + ); + } + + #[tokio::test] + async fn resolve_tags_unknown_shortcode_is_filtered_out() { + let (url, _calls) = fake_query_server(palette_response()).await; + let client = test_client(&url); + // :notarealemoji: is not in the palette — must produce no tags. + let tags = resolve_emoji_tags_for_content(&client, ":notarealemoji:") + .await + .unwrap(); + assert!(tags.is_empty()); + } + + #[tokio::test] + async fn resolve_tags_deduplicates_repeated_shortcode() { + let (url, _calls) = fake_query_server(palette_response()).await; + let client = test_client(&url); + // `:wave:` appears twice; output must have exactly one tag for it. + let tags = resolve_emoji_tags_for_content(&client, ":wave: and :wave: again") + .await + .unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0][1], "wave"); + } + + #[tokio::test] + async fn resolve_tags_first_appearance_order() { + let (url, _calls) = fake_query_server(palette_response()).await; + let client = test_client(&url); + // `:sweatblob:` before `:wave:` — tags must appear in that order. + let tags = resolve_emoji_tags_for_content(&client, ":sweatblob: :wave:") + .await + .unwrap(); + assert_eq!(tags.len(), 2); + assert_eq!(tags[0][1], "sweatblob"); + assert_eq!(tags[1][1], "wave"); + } + + #[tokio::test] + async fn resolve_tags_case_insensitive_match_emits_lowercase() { + let (url, _calls) = fake_query_server(palette_response()).await; + let client = test_client(&url); + // `:WAVE:` must resolve to the lowercase `wave` tag. + let tags = resolve_emoji_tags_for_content(&client, ":WAVE:") + .await + .unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!( + tags[0][1], "wave", + "canonical tag shortcode must be lowercase" + ); + } + + #[tokio::test] + async fn resolve_tags_no_colon_content_skips_palette_query() { + let (url, call_count) = fake_query_server(palette_response()).await; + let client = test_client(&url); + // Content with no `:` must return empty tags with ZERO relay queries. + let tags = resolve_emoji_tags_for_content(&client, "Hello world, no colons here") + .await + .unwrap(); + assert!(tags.is_empty()); + assert_eq!( + *call_count.lock().unwrap(), + 0, + "must not query the palette when content has no colon" + ); + } + + #[tokio::test] + async fn resolve_tags_unknown_only_content_still_queries_once() { + let (url, call_count) = fake_query_server(palette_response()).await; + let client = test_client(&url); + // Content has `:` but the shortcode is not in the palette. + // One palette query should occur (candidates are non-empty), zero tags returned. + let tags = resolve_emoji_tags_for_content(&client, ":notreal:") + .await + .unwrap(); + assert!(tags.is_empty()); + assert_eq!( + *call_count.lock().unwrap(), + 1, + "must query palette once even when no shortcodes resolve" + ); + } } diff --git a/crates/buzz-cli/src/commands/gifs.rs b/crates/buzz-cli/src/commands/gifs.rs index c40d183ca3f..7dffae295be 100644 --- a/crates/buzz-cli/src/commands/gifs.rs +++ b/crates/buzz-cli/src/commands/gifs.rs @@ -100,20 +100,15 @@ fn default_locale() -> String { // NIP-11 descriptor resolution // --------------------------------------------------------------------------- -/// Resolve the relay's `gif` descriptor from its NIP-11 document. +/// Parse the `gif` descriptor from a decoded NIP-11 JSON document. /// -/// Returns `(search_path, share_path)` as validated relay-relative strings. -/// Fails with a clear `CliError` if: -/// - the relay does not advertise `buzz-gif` -/// - the provider is not `klipy` -/// - either path is absent or fails the `safe_relay_path` check -pub(crate) async fn resolve_gif_descriptor( - client: &BuzzClient, +/// Shared between `resolve_gif_descriptor` (which fetches the document) and +/// tests (which inject a synthetic document directly). Separating the pure +/// parse logic from the I/O call makes the descriptor gates directly testable +/// without a fake HTTP server. +pub(crate) fn parse_gif_descriptor_info( + info: &serde_json::Value, ) -> Result<(String, String), CliError> { - let raw = client.get_public("/info").await?; - let info: serde_json::Value = serde_json::from_str(&raw) - .map_err(|e| CliError::Other(format!("invalid NIP-11 response: {e}")))?; - // Gate 1: `supported_extensions` must contain `"buzz-gif"`. let extensions = info .get("supported_extensions") @@ -163,6 +158,22 @@ pub(crate) async fn resolve_gif_descriptor( Ok((search, share)) } +/// Resolve the relay's `gif` descriptor from its NIP-11 document. +/// +/// Returns `(search_path, share_path)` as validated relay-relative strings. +/// Fails with a clear `CliError` if: +/// - the relay does not advertise `buzz-gif` +/// - the provider is not `klipy` +/// - either path is absent or fails the `safe_relay_path` check +pub(crate) async fn resolve_gif_descriptor( + client: &BuzzClient, +) -> Result<(String, String), CliError> { + let raw = client.get_public("/info").await?; + let info: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("invalid NIP-11 response: {e}")))?; + parse_gif_descriptor_info(&info) +} + // --------------------------------------------------------------------------- // Response normalization // --------------------------------------------------------------------------- @@ -468,57 +479,20 @@ mod tests { } // ----------------------------------------------------------------------- - // resolve_gif_descriptor — pure parsing against a typed descriptor + // parse_gif_descriptor_info — production gate logic, no I/O // ----------------------------------------------------------------------- - /// Parse a JSON NIP-11 fragment the same way `resolve_gif_descriptor` does, - /// returning `Ok((search, share))` or `Err(msg)`. Extracted as a pure fn - /// so tests can drive the full gate logic without I/O. - fn parse_descriptor(info: &serde_json::Value) -> Result<(String, String), String> { - let extensions = info - .get("supported_extensions") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::>()) - .unwrap_or_default(); - if !extensions.iter().any(|&e| e == REQUIRED_EXTENSION) { - return Err(format!("missing {REQUIRED_EXTENSION}")); - } - let gif = info - .get("gif") - .ok_or_else(|| "no gif descriptor".to_string())?; - let provider = gif.get("provider").and_then(|v| v.as_str()).unwrap_or(""); - if provider != REQUIRED_PROVIDER { - return Err(format!("wrong provider: {provider}")); - } - let search = gif - .get("search") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let share = gif - .get("share") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - if !safe_relay_path(&search) { - return Err(format!("unsafe search path: {search:?}")); - } - if !safe_relay_path(&share) { - return Err(format!("unsafe share path: {share:?}")); - } - Ok((search, share)) - } - #[test] fn descriptor_missing_extension_is_rejected() { let info = serde_json::json!({ "supported_extensions": ["buzz-emoji"], "gif": { "provider": "klipy", "search": "/gifs/search", "share": "/gifs/share" } }); - assert!(parse_descriptor(&info).is_err()); - assert!(parse_descriptor(&info) - .unwrap_err() - .contains("missing buzz-gif")); + let err = parse_gif_descriptor_info(&info).unwrap_err(); + assert!( + err.to_string().contains("buzz-gif"), + "error must mention buzz-gif, got: {err}" + ); } #[test] @@ -527,9 +501,11 @@ mod tests { "supported_extensions": ["buzz-gif"], "gif": { "provider": "tenor", "search": "/gifs/search", "share": "/gifs/share" } }); - assert!(parse_descriptor(&info) - .unwrap_err() - .contains("wrong provider")); + let err = parse_gif_descriptor_info(&info).unwrap_err(); + assert!( + err.to_string().contains("tenor"), + "error must mention the bad provider, got: {err}" + ); } #[test] @@ -538,9 +514,11 @@ mod tests { "supported_extensions": ["buzz-gif"], "gif": { "provider": "klipy", "search": "//attacker.example/x", "share": "/gifs/share" } }); - assert!(parse_descriptor(&info) - .unwrap_err() - .contains("unsafe search path")); + let err = parse_gif_descriptor_info(&info).unwrap_err(); + assert!( + err.to_string().contains("search path"), + "error must mention search path, got: {err}" + ); } #[test] @@ -549,9 +527,11 @@ mod tests { "supported_extensions": ["buzz-gif"], "gif": { "provider": "klipy", "search": "/gifs/search", "share": "/gifs/../admin" } }); - assert!(parse_descriptor(&info) - .unwrap_err() - .contains("unsafe share path")); + let err = parse_gif_descriptor_info(&info).unwrap_err(); + assert!( + err.to_string().contains("share path"), + "error must mention share path, got: {err}" + ); } #[test] @@ -560,7 +540,7 @@ mod tests { "supported_extensions": ["buzz-gif"], "gif": { "provider": "klipy", "search": "/gifs/search", "share": "/gifs/share" } }); - let (search, share) = parse_descriptor(&info).unwrap(); + let (search, share) = parse_gif_descriptor_info(&info).unwrap(); assert_eq!(search, "/gifs/search"); assert_eq!(share, "/gifs/share"); } From dd2a232e3185a1e1e45291a09fed80f23241bac7 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 17:03:02 -0400 Subject: [PATCH 5/9] test(cli): address pass-3 test-teeth findings - gifs.rs: fake NIP-11 now advertises non-default paths /x/search-alt and /x/share-alt, proving production reads relay-advertised paths rather than using hardcoded defaults - gifs.rs: add test_client_with_tag() + search_forwards_x_auth_tag_header asserting the x-auth-tag header equals the exact tag JSON; add search_nip98_token_has_correct_u_method_and_payload_hash decoding the NIP-98 base64 token and asserting u, method, and payload tags - gifs.rs: search_output_contains_cdn_url replaced by search_entries_returns_top_level_cdn_url, which calls the production search_entries() helper and asserts entries[0].cdn_url is set to the expected normalized URL; raw-passthrough regression now fails the test - messages.rs: add cmd_send_message_attaches_emoji_tags_for_known_shortcodes and cmd_send_message_skips_palette_query_when_no_colon_in_content driving cmd_send_message against a fake /query + /events relay, asserting the submitted raw event carries emoji tags for known shortcodes and zero palette queries for no-colon content; removing the resolver call or passing &[] at :718 causes both tests to fail Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-cli/src/commands/gifs.rs | 386 ++++++++++++++++------- crates/buzz-cli/src/commands/messages.rs | 226 ++++++++++++- 2 files changed, 497 insertions(+), 115 deletions(-) diff --git a/crates/buzz-cli/src/commands/gifs.rs b/crates/buzz-cli/src/commands/gifs.rs index 7dffae295be..84e772b80e0 100644 --- a/crates/buzz-cli/src/commands/gifs.rs +++ b/crates/buzz-cli/src/commands/gifs.rs @@ -323,6 +323,24 @@ pub async fn cmd_search( query: &str, locale: Option<&str>, ) -> Result<(), CliError> { + let entries = search_entries(client, query, locale).await?; + println!( + "{}", + serde_json::to_string(&entries) + .map_err(|e| CliError::Other(format!("output serialization failed: {e}")))? + ); + Ok(()) +} + +/// Resolve NIP-11, POST the search, normalize and return typed GIF entries. +/// +/// Extracted from `cmd_search` so tests can assert the typed result directly +/// without capturing stdout. +pub(crate) async fn search_entries( + client: &BuzzClient, + query: &str, + locale: Option<&str>, +) -> Result, CliError> { let (search_path, _) = resolve_gif_descriptor(client).await?; let cid = customer_id( client.keys().secret_key().as_secret_bytes(), @@ -336,13 +354,7 @@ pub async fn cmd_search( "locale": locale, }); let raw = client.post_json_authed(&search_path, &body).await?; - let entries = normalize_gif_response(&raw)?; - println!( - "{}", - serde_json::to_string(&entries) - .map_err(|e| CliError::Other(format!("output serialization failed: {e}")))? - ); - Ok(()) + normalize_gif_response(&raw) } /// `buzz gifs share --slug ` @@ -656,24 +668,37 @@ mod tests { use axum::http::{HeaderMap, StatusCode}; use axum::routing::post; use axum::Router; - use nostr::Keys; + use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; + use nostr::{JsonUtil, Keys, Tag}; use std::net::SocketAddr; use std::sync::{Arc, Mutex}; use tokio::net::TcpListener; /// Captured request data from the fake server. #[derive(Clone, Default)] - #[allow(dead_code)] struct Captured { - method: String, path: String, auth_header: String, auth_tag_header: String, body: String, } - /// A simple fake relay: serves NIP-11 at `/info` and captures POST bodies - /// at `/gifs/search` and `/gifs/share`. + /// NIP-11 JSON that advertises non-default search/share paths. + /// + /// Production code must read the advertised paths from NIP-11 and POST to + /// them. Using non-default paths here means hardcoded "/gifs/search" / + /// "/gifs/share" in production would target 404 routes and the tests would + /// fail — proving that the relay-advertised path is actually used. + const ALT_SEARCH_PATH: &str = "/x/search-alt"; + const ALT_SHARE_PATH: &str = "/x/share-alt"; + + fn alt_nip11() -> &'static str { + // Embedded as a literal so there is no run-time allocation in the const. + r#"{"supported_extensions":["buzz-gif"],"gif":{"provider":"klipy","search":"/x/search-alt","share":"/x/share-alt"}}"# + } + + /// A simple fake relay: serves NIP-11 at `/info` advertising non-default + /// paths, then captures POST bodies at those paths. async fn fake_server( search_status: StatusCode, search_body: String, @@ -684,77 +709,76 @@ mod tests { type S = (Arc>>, StatusCode, String, StatusCode); let state: S = (captured.clone(), search_status, search_body, share_status); - let app = Router::new() - .route( - "/info", - axum::routing::get(|| async { - ( - StatusCode::OK, - [("content-type", "application/nostr+json")], - r#"{"supported_extensions":["buzz-gif"],"gif":{"provider":"klipy","search":"/gifs/search","share":"/gifs/share"}}"#, - ) - }), - ) - .route( - "/gifs/search", - post( - |State((cap, search_st, search_bd, _)): State, - headers: HeaderMap, - body: Bytes| async move { - let body_str = String::from_utf8_lossy(&body).to_string(); - cap.lock().unwrap().push(Captured { - method: "POST".to_string(), - path: "/gifs/search".to_string(), - auth_header: headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(), - auth_tag_header: headers - .get("x-auth-tag") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(), - body: body_str, - }); - axum::response::Response::builder() - .status(search_st) - .header("content-type", "application/json") - .body(axum::body::Body::from(search_bd.clone())) - .unwrap() - }, - ), - ) - .route( - "/gifs/share", - post( - |State((cap, _, _, share_st)): State, - headers: HeaderMap, - body: Bytes| async move { - let body_str = String::from_utf8_lossy(&body).to_string(); - cap.lock().unwrap().push(Captured { - method: "POST".to_string(), - path: "/gifs/share".to_string(), - auth_header: headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(), - auth_tag_header: headers - .get("x-auth-tag") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(), - body: body_str, - }); - axum::response::Response::builder() - .status(share_st) - .body(axum::body::Body::empty()) - .unwrap() - }, - ), - ) - .with_state(state); + let app = + Router::new() + .route( + "/info", + axum::routing::get(|| async { + ( + StatusCode::OK, + [("content-type", "application/nostr+json")], + alt_nip11(), + ) + }), + ) + .route( + ALT_SEARCH_PATH, + post( + |State((cap, search_st, search_bd, _)): State, + headers: HeaderMap, + body: Bytes| async move { + let body_str = String::from_utf8_lossy(&body).to_string(); + cap.lock().unwrap().push(Captured { + path: ALT_SEARCH_PATH.to_string(), + auth_header: headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(), + auth_tag_header: headers + .get("x-auth-tag") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(), + body: body_str, + }); + axum::response::Response::builder() + .status(search_st) + .header("content-type", "application/json") + .body(axum::body::Body::from(search_bd.clone())) + .unwrap() + }, + ), + ) + .route( + ALT_SHARE_PATH, + post( + |State((cap, _, _, share_st)): State, + headers: HeaderMap, + body: Bytes| async move { + let body_str = String::from_utf8_lossy(&body).to_string(); + cap.lock().unwrap().push(Captured { + path: ALT_SHARE_PATH.to_string(), + auth_header: headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(), + auth_tag_header: headers + .get("x-auth-tag") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(), + body: body_str, + }); + axum::response::Response::builder() + .status(share_st) + .body(axum::body::Body::empty()) + .unwrap() + }, + ), + ) + .with_state(state); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr: SocketAddr = listener.local_addr().unwrap(); @@ -762,33 +786,67 @@ mod tests { (format!("http://{addr}"), captured) } + /// Client without an auth tag — used for basic NIP-98 / body / path tests. fn test_client(base_url: &str) -> BuzzClient { let keys = Keys::generate(); BuzzClient::new(base_url.to_string(), keys, None, None).unwrap() } - #[tokio::test] - async fn search_sends_nip98_auth_and_correct_body() { - let search_resp = r#"{"result":true,"data":{"data":[ + /// Client with a synthetic `x-auth-tag` — used to assert that the header + /// is forwarded verbatim and that its value is the raw JSON of the tag. + fn test_client_with_tag(base_url: &str) -> (BuzzClient, String) { + let keys = Keys::generate(); + // Construct a minimal auth tag: ["auth", , "conditions", ] + let owner_hex = "a".repeat(64); + let sig_hex = "b".repeat(128); + let tag_vec = vec![ + "auth".to_string(), + owner_hex, + "conditions".to_string(), + sig_hex, + ]; + let tag_json = serde_json::to_string(&tag_vec).unwrap(); + let tag = Tag::parse(tag_vec).unwrap(); + let client = BuzzClient::new( + base_url.to_string(), + keys, + Some(tag), + Some(tag_json.clone()), + ) + .unwrap(); + (client, tag_json) + } + + fn one_gif_response() -> String { + serde_json::json!({"result":true,"data":{"data":[ {"type":"gif","slug":"test-slug","title":"Test","file":{ "md":{"gif":{"url":"https://cdn.klipy.com/test.gif","width":320,"height":180,"size":50}} }} - ]}}"#.to_string(); + ]}}) + .to_string() + } + + // ── item 1: relay-advertised path binding ────────────────────────────── + + #[tokio::test] + async fn search_posts_to_relay_advertised_path_not_hardcoded() { + // Fake advertises ALT_SEARCH_PATH; hardcoded "/gifs/search" would 404. let (url, captured) = - fake_server(StatusCode::OK, search_resp, StatusCode::NO_CONTENT).await; + fake_server(StatusCode::OK, one_gif_response(), StatusCode::NO_CONTENT).await; let client = test_client(&url); cmd_search(&client, "hello", Some("en_US")).await.unwrap(); let calls = captured.lock().unwrap(); - let call = calls.iter().find(|c| c.path == "/gifs/search").unwrap(); - // NIP-98 Authorization header must be present and start with "Nostr ". + let call = calls + .iter() + .find(|c| c.path == ALT_SEARCH_PATH) + .expect("POST must arrive at the NIP-11-advertised path"); assert!( call.auth_header.starts_with("Nostr "), - "Authorization must be NIP-98 Nostr token, got: {:?}", + "Authorization must be a NIP-98 Nostr token, got: {:?}", call.auth_header ); - // Body must contain the expected fields. let body: serde_json::Value = serde_json::from_str(&call.body).unwrap(); assert_eq!(body["query"], "hello"); assert_eq!(body["locale"], "en_US"); @@ -802,20 +860,8 @@ mod tests { } #[tokio::test] - async fn search_output_contains_cdn_url() { - let search_resp = r#"{"result":true,"data":{"data":[ - {"type":"gif","slug":"test-slug","title":"Test","file":{ - "md":{"gif":{"url":"https://cdn.klipy.com/test.gif","width":320,"height":180,"size":50}} - }} - ]}}"#.to_string(); - let (url, _) = fake_server(StatusCode::OK, search_resp, StatusCode::NO_CONTENT).await; - let client = test_client(&url); - // If cmd_search completes without error, the normalized output was valid. - cmd_search(&client, "", None).await.unwrap(); - } - - #[tokio::test] - async fn share_sends_nip98_auth_and_correct_body() { + async fn share_posts_to_relay_advertised_path_not_hardcoded() { + // Fake advertises ALT_SHARE_PATH; hardcoded "/gifs/share" would 404. let (url, captured) = fake_server(StatusCode::OK, "[]".to_string(), StatusCode::NO_CONTENT).await; let client = test_client(&url); @@ -823,10 +869,13 @@ mod tests { cmd_share(&client, "my-gif-slug").await.unwrap(); let calls = captured.lock().unwrap(); - let call = calls.iter().find(|c| c.path == "/gifs/share").unwrap(); + let call = calls + .iter() + .find(|c| c.path == ALT_SHARE_PATH) + .expect("POST must arrive at the NIP-11-advertised share path"); assert!( call.auth_header.starts_with("Nostr "), - "Authorization must be NIP-98 Nostr token" + "Authorization must be a NIP-98 Nostr token" ); let body: serde_json::Value = serde_json::from_str(&call.body).unwrap(); assert_eq!(body["slug"], "my-gif-slug"); @@ -839,11 +888,128 @@ mod tests { ); } + // ── item 2: x-auth-tag forwarded + NIP-98 deep assertions ───────────── + + #[tokio::test] + async fn search_forwards_x_auth_tag_header() { + let (url, captured) = + fake_server(StatusCode::OK, one_gif_response(), StatusCode::NO_CONTENT).await; + let (client, expected_tag_json) = test_client_with_tag(&url); + + cmd_search(&client, "", None).await.unwrap(); + + let calls = captured.lock().unwrap(); + let call = calls + .iter() + .find(|c| c.path == ALT_SEARCH_PATH) + .expect("search POST must arrive"); + assert_eq!( + call.auth_tag_header, expected_tag_json, + "x-auth-tag must equal the exact JSON of the auth tag" + ); + } + + #[tokio::test] + async fn search_nip98_token_has_correct_u_method_and_payload_hash() { + let (url, captured) = + fake_server(StatusCode::OK, one_gif_response(), StatusCode::NO_CONTENT).await; + let client = test_client(&url); + + cmd_search(&client, "cats", Some("en_US")).await.unwrap(); + + let calls = captured.lock().unwrap(); + let call = calls + .iter() + .find(|c| c.path == ALT_SEARCH_PATH) + .expect("search POST must arrive"); + + // Decode "Nostr " → JSON event + let token = call + .auth_header + .strip_prefix("Nostr ") + .expect("must start with Nostr "); + let json_bytes = B64.decode(token).expect("must be valid base64"); + let event: nostr::Event = + nostr::Event::from_json(std::str::from_utf8(&json_bytes).unwrap()).unwrap(); + + // kind:27235 (NIP-98) + assert_eq!(event.kind.as_u16(), 27235); + + // `u` tag must be the exact POST URL + let expected_url = format!("{url}{ALT_SEARCH_PATH}"); + let u_tag = event + .tags + .iter() + .find(|t| t.as_slice().first().map(|s| s.as_str()) == Some("u")) + .expect("NIP-98 event must have a u tag"); + assert_eq!( + u_tag.as_slice().get(1).map(|s| s.as_str()).unwrap_or(""), + expected_url + ); + + // `method` tag must be "POST" + let method_tag = event + .tags + .iter() + .find(|t| t.as_slice().first().map(|s| s.as_str()) == Some("method")) + .expect("NIP-98 event must have a method tag"); + assert_eq!( + method_tag + .as_slice() + .get(1) + .map(|s| s.as_str()) + .unwrap_or(""), + "POST" + ); + + // `payload` tag must equal SHA-256 of the request body + use sha2::{Digest, Sha256}; + let body_bytes = call.body.as_bytes(); + let expected_hash = hex::encode(Sha256::digest(body_bytes)); + let payload_tag = event + .tags + .iter() + .find(|t| t.as_slice().first().map(|s| s.as_str()) == Some("payload")) + .expect("NIP-98 event must have a payload tag for POST with body"); + assert_eq!( + payload_tag + .as_slice() + .get(1) + .map(|s| s.as_str()) + .unwrap_or(""), + expected_hash, + "payload tag must be SHA-256 of the request body" + ); + } + + // ── item 3: search_output_contains_cdn_url asserts typed result ──────── + + #[tokio::test] + async fn search_entries_returns_top_level_cdn_url() { + // Tests that cmd_search delegates to search_entries() which returns + // typed output with cdn_url at the top level. A raw-passthrough + // regression (no normalize_gif_response) would produce a different + // struct shape and cdn_url would be absent. + let (url, _) = + fake_server(StatusCode::OK, one_gif_response(), StatusCode::NO_CONTENT).await; + let client = test_client(&url); + + let entries = search_entries(&client, "", None).await.unwrap(); + + assert!(!entries.is_empty(), "must return at least one entry"); + assert_eq!( + entries[0].cdn_url, "https://cdn.klipy.com/test.gif", + "cdn_url must be the normalized top-level URL from md.gif" + ); + assert_eq!(entries[0].slug, "test-slug"); + } + + // ── existing negative gate ───────────────────────────────────────────── + #[tokio::test] async fn share_returns_accepted_true_on_204() { let (url, _) = fake_server(StatusCode::OK, "[]".to_string(), StatusCode::NO_CONTENT).await; let client = test_client(&url); - // Completes without error = accepted. cmd_share(&client, "slug-abc").await.unwrap(); } @@ -856,7 +1022,7 @@ mod tests { ( StatusCode::OK, [("content-type", "application/nostr+json")], - r#"{"supported_extensions":[],"gif":{"provider":"klipy","search":"/gifs/search","share":"/gifs/share"}}"#, + r#"{"supported_extensions":[],"gif":{"provider":"klipy","search":"/x/search-alt","share":"/x/share-alt"}}"#, ) }), ); diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 1048e63fd47..a3144b1f22e 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -1067,11 +1067,11 @@ pub async fn dispatch( #[cfg(test)] mod tests { use super::{ - channel_id_from_event, cmd_get_thread, event_mention_pubkeys, find_root_from_tags, - format_events, 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, + channel_id_from_event, cmd_get_thread, cmd_send_message, event_mention_pubkeys, + find_root_from_tags, format_events, 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, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, @@ -1581,4 +1581,220 @@ mod tests { ]; assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1); } + + // ── cmd_send_message — emoji-tag binding seam ───────────────────────── + // + // These tests drive `cmd_send_message` through a minimal fake relay + // serving `/query` (emoji palette) and `/events` (event submission). + // + // Content with no `@` and no explicit mentions bypasses member-resolution + // relay calls, so the only relay traffic is: + // 1. POST /query — emoji palette fetch (when content has `:`) + // 2. POST /events — signed event submission + // + // Removing the resolver call at messages.rs:687-691 or passing &[] at + // :718 would cause the emoji-tag assertions below to fail. + + use axum::body::Bytes as AxumBytes; + use axum::extract::State as AxumState; + use axum::http::{HeaderMap as AxumHeaderMap, StatusCode as AxumStatusCode}; + use axum::routing::post as axum_post; + use axum::Router as AxumRouter; + use std::net::SocketAddr as StdSocketAddr; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc as StdArc; + use tokio::net::TcpListener as TokioTcpListener; + + /// Captured body of a POST /events call. + #[derive(Clone, Default)] + struct CapturedEvent { + body: String, + } + + /// Minimal fake relay for send-path tests. + /// + /// - `/query` returns the given `query_body` on every call and increments + /// `query_count`. + /// - `/events` returns `{"event_id":"fake","accepted":true}` and records + /// the raw event JSON in `captured_event`. + async fn fake_send_relay( + query_body: String, + ) -> ( + String, + StdArc, + StdArc>>, + ) { + let query_count = StdArc::new(AtomicU32::new(0)); + let captured_event: StdArc>> = + StdArc::new(std::sync::Mutex::new(None)); + + type S = ( + StdArc, + String, + StdArc>>, + ); + let state: S = (query_count.clone(), query_body, captured_event.clone()); + + let app = AxumRouter::new() + .route( + "/query", + axum_post( + |AxumState((count, body, _)): AxumState, + _headers: AxumHeaderMap, + _req: AxumBytes| async move { + count.fetch_add(1, Ordering::Relaxed); + ( + AxumStatusCode::OK, + [("content-type", "application/json")], + body, + ) + }, + ), + ) + .route( + "/events", + axum_post( + |AxumState((_, _, cap)): AxumState, + _headers: AxumHeaderMap, + body: AxumBytes| async move { + let body_str = String::from_utf8_lossy(&body).to_string(); + *cap.lock().unwrap() = Some(CapturedEvent { body: body_str }); + ( + AxumStatusCode::OK, + [("content-type", "application/json")], + r#"{"event_id":"fake0000","accepted":true}"#, + ) + }, + ), + ) + .with_state(state); + + 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}"), query_count, captured_event) + } + + /// Palette JSON with one emoji: `wave` → some URL. + fn send_palette_response() -> String { + serde_json::json!([{ + "created_at": 100, + "tags": [ + ["d", "buzz:custom-emoji"], + ["emoji", "wave", "https://cdn.example.com/wave.png"], + ["emoji", "sweatblob", "https://cdn.example.com/sweatblob.gif"] + ] + }]) + .to_string() + } + + /// A valid channel UUID used across send-path tests. + const SEND_TEST_CHANNEL: &str = "123e4567-e89b-12d3-a456-426614174000"; + + fn send_params(content: &str) -> super::SendMessageParams { + super::SendMessageParams { + channel_id: SEND_TEST_CHANNEL.to_string(), + content: content.to_string(), + kind: None, + reply_to: None, + broadcast: false, + files: vec![], + mentions: vec![], + } + } + + #[tokio::test] + async fn cmd_send_message_attaches_emoji_tags_for_known_shortcodes() { + // Content contains `:wave:` which resolves in the palette. + // The submitted event must carry an `emoji` tag for `wave`. + let (url, query_count, captured_event) = fake_send_relay(send_palette_response()).await; + let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + + cmd_send_message(&client, send_params("hello :wave: everyone")) + .await + .unwrap(); + + // Palette was queried at least once (short-circuit was NOT triggered). + assert!( + query_count.load(Ordering::Relaxed) >= 1, + "palette must be queried when content has a colon" + ); + + // Submitted event must contain an emoji tag for `wave`. + let raw = captured_event.lock().unwrap(); + let raw = raw.as_ref().expect("event must have been submitted"); + let event: serde_json::Value = serde_json::from_str(&raw.body).unwrap(); + let tags: Vec> = event["tags"] + .as_array() + .unwrap() + .iter() + .map(|t| { + t.as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap_or("").to_string()) + .collect() + }) + .collect(); + let emoji_tags: Vec<&Vec> = tags + .iter() + .filter(|t| t.first().map(|s| s.as_str()) == Some("emoji")) + .collect(); + assert!( + emoji_tags + .iter() + .any(|t| t.get(1).map(|s| s.as_str()) == Some("wave")), + "submitted event must have an emoji tag for `wave`, got tags: {tags:?}" + ); + // Unknown shortcodes must not produce tags. + assert!( + !emoji_tags + .iter() + .any(|t| t.get(1).map(|s| s.as_str()) == Some("notreal")), + "unknown shortcodes must not produce emoji tags" + ); + } + + #[tokio::test] + async fn cmd_send_message_skips_palette_query_when_no_colon_in_content() { + // Content has no `:` at all — the palette query must be skipped + // entirely (zero RTTs), and the submitted event must have no emoji tags. + let (url, query_count, captured_event) = fake_send_relay(send_palette_response()).await; + let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + + cmd_send_message(&client, send_params("plain message no colons")) + .await + .unwrap(); + + assert_eq!( + query_count.load(Ordering::Relaxed), + 0, + "palette must NOT be queried when content has no colon" + ); + + // Submitted event must have no emoji tags. + let raw = captured_event.lock().unwrap(); + let raw = raw.as_ref().expect("event must have been submitted"); + let event: serde_json::Value = serde_json::from_str(&raw.body).unwrap(); + let tags: Vec> = event["tags"] + .as_array() + .unwrap() + .iter() + .map(|t| { + t.as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap_or("").to_string()) + .collect() + }) + .collect(); + let emoji_tags: Vec<&Vec> = tags + .iter() + .filter(|t| t.first().map(|s| s.as_str()) == Some("emoji")) + .collect(); + assert!( + emoji_tags.is_empty(), + "no-colon content must produce no emoji tags, got: {emoji_tags:?}" + ); + } } From 2e930da4abcaa12035478456e0bab663383c1fa3 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 17:09:59 -0400 Subject: [PATCH 6/9] fix(cli): gate emoji palette resolution to kind 9 only Forum sends (kind 45001/45003) previously resolved the workspace palette before kind selection, paying the relay query and then discarding the result because the forum SDK builders do not accept emoji_tags. Move the resolution into the None | Some(9) match arm so it only runs for the kind that actually uses it. The kind-9 happy and short-circuit paths are unchanged; the existing cmd_send_message seam tests cover both. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-cli/src/commands/messages.rs | 45 +++++++++++++----------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index a3144b1f22e..6e4cd2b4ea8 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -680,16 +680,6 @@ pub async fn cmd_send_message( let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect(); - // Scan final_content for `:shortcode:` patterns and attach NIP-30 emoji - // tags for any that resolve in the workspace palette. The palette fetch - // (one relay query) is skipped entirely when the content has no candidate - // `:…:` sequence, keeping normal message sends at zero extra RTTs. - let emoji_tags = if final_content.contains(':') { - crate::commands::emoji::resolve_emoji_tags_for_content(client, &final_content).await? - } else { - Vec::new() - }; - let builder = match p.kind { Some(45001) => { buzz_sdk::build_forum_post(channel_uuid, &final_content, &mention_refs, &media_tags) @@ -708,16 +698,31 @@ pub async fn cmd_send_message( ) .map_err(|e| CliError::Other(format!("build_forum_comment failed: {e}")))? } - None | Some(9) => buzz_sdk::build_message( - channel_uuid, - &final_content, - thread_ref.as_ref(), - &mention_refs, - p.broadcast, - &media_tags, - &emoji_tags, - ) - .map_err(|e| CliError::Other(format!("build_message failed: {e}")))?, + None | Some(9) => { + // Scan final_content for `:shortcode:` patterns and attach NIP-30 + // emoji tags for any that resolve in the workspace palette. + // Palette resolution is scoped to kind 9: forum builders (45001, + // 45003) do not accept emoji_tags, so resolving early would pay + // the relay query and immediately discard the result. + // The fetch is skipped entirely when content has no `:`, keeping + // plain sends at zero extra RTTs. + let emoji_tags = if final_content.contains(':') { + crate::commands::emoji::resolve_emoji_tags_for_content(client, &final_content) + .await? + } else { + Vec::new() + }; + buzz_sdk::build_message( + channel_uuid, + &final_content, + thread_ref.as_ref(), + &mention_refs, + p.broadcast, + &media_tags, + &emoji_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)" From ef2adb4dc3e6be0daa07a5d31556ed2da0cf6c33 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 17:21:55 -0400 Subject: [PATCH 7/9] fix(cli): appease clippy --workspace --all-targets lints in gifs.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three needless-borrow / manual_contains / needless_splitn warnings in gifs.rs that the pre-push hook missed (hook scopes clippy to the desktop-tauri manifest only): - splitn(2, '.') → split('.') (only .next() consumed) - .iter().any(|&e| e == REQUIRED_EXTENSION) → .contains(&REQUIRED_EXTENSION) - hex::encode(&sk) → hex::encode(sk) in test (needless borrow for generic arg) Behavior unchanged. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-cli/src/commands/gifs.rs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/crates/buzz-cli/src/commands/gifs.rs b/crates/buzz-cli/src/commands/gifs.rs index 84e772b80e0..0352d2cdb37 100644 --- a/crates/buzz-cli/src/commands/gifs.rs +++ b/crates/buzz-cli/src/commands/gifs.rs @@ -80,13 +80,7 @@ fn default_locale() -> String { std::env::var("LANG") .ok() .and_then(|l| { - let code: String = l - .splitn(2, '.') - .next() - .unwrap_or("") - .chars() - .take(5) - .collect(); + let code: String = l.split('.').next().unwrap_or("").chars().take(5).collect(); if code.len() >= 2 { Some(code) } else { @@ -115,7 +109,7 @@ pub(crate) fn parse_gif_descriptor_info( .and_then(|v| v.as_array()) .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::>()) .unwrap_or_default(); - if !extensions.iter().any(|&e| e == REQUIRED_EXTENSION) { + if !extensions.contains(&REQUIRED_EXTENSION) { return Err(CliError::Other(format!( "this relay does not support GIF search (missing \"{REQUIRED_EXTENSION}\" in supported_extensions)" ))); @@ -472,7 +466,7 @@ mod tests { use sha2::{Digest, Sha256}; let sk = [0xde_u8; 32]; // What the old pubkey-hash approach would have produced (approximately): - let naive_hash = hex::encode(&Sha256::digest(hex::encode(&sk).as_bytes())[..16]); + let naive_hash = hex::encode(&Sha256::digest(hex::encode(sk).as_bytes())[..16]); let actual = customer_id(&sk, "https://relay.example"); assert_ne!( actual, naive_hash, From d13a195cd5869597a28eb3063e421a7994c2bc99 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 18:23:50 -0400 Subject: [PATCH 8/9] fix(cli): normalize palette entries and degrade emoji errors gracefully P2: emoji_tags_of now normalizes shortcodes to lowercase (relay stores original case but scan_shortcodes always lowercases, so an uppercase stored key like "WAVE" would never resolve), skips entries with empty or missing URLs, and keeps only the first occurrence of each normalized shortcode within one event. Mirrors desktop customEmojiFromTags. P3: The ? on resolve_emoji_tags_for_content inside cmd_send_message is replaced with a match that logs a stderr warning and falls back to an empty emoji-tag list. Palette enrichment is decorative; a fetch failure must not abort delivery of an otherwise-valid message. Tests added: - emoji_tags_of_normalizes_uppercase_shortcode_to_lowercase - emoji_tags_of_skips_empty_url - emoji_tags_of_first_occurrence_wins_within_event - cmd_send_message_succeeds_when_palette_query_errors Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-cli/src/commands/emoji.rs | 86 ++++++++++++++++++++-- crates/buzz-cli/src/commands/messages.rs | 92 +++++++++++++++++++++++- 2 files changed, 170 insertions(+), 8 deletions(-) diff --git a/crates/buzz-cli/src/commands/emoji.rs b/crates/buzz-cli/src/commands/emoji.rs index af3dc007cab..0462712575d 100644 --- a/crates/buzz-cli/src/commands/emoji.rs +++ b/crates/buzz-cli/src/commands/emoji.rs @@ -16,10 +16,19 @@ struct EmojiEntry { } /// Parse `["emoji", shortcode, url]` tags from one event into entries. +/// +/// Mirrors desktop `customEmojiFromTags` (`desktop/src/shared/api/customEmoji.ts`): +/// - Shortcode is normalized to lowercase (relay stores original case; scanner +/// always lowercases, so an upper-case stored key would never resolve without +/// this step). +/// - Entries with a missing or empty URL are skipped. +/// - Within one event the first occurrence of a normalized shortcode wins; +/// later duplicates are dropped. fn emoji_tags_of(event: &serde_json::Value) -> Vec { let Some(tags) = event.get("tags").and_then(|v| v.as_array()) else { return vec![]; }; + let mut seen = std::collections::HashSet::new(); let mut out = Vec::new(); for tag in tags { let Some(parts) = tag.as_array() else { @@ -28,16 +37,26 @@ fn emoji_tags_of(event: &serde_json::Value) -> Vec { if parts.first().and_then(|v| v.as_str()) != Some("emoji") { continue; } - let (Some(shortcode), Some(url)) = ( + let (Some(raw_shortcode), Some(url)) = ( parts.get(1).and_then(|v| v.as_str()), parts.get(2).and_then(|v| v.as_str()), ) else { continue; }; - out.push(EmojiEntry { - shortcode: shortcode.to_string(), - url: url.to_string(), - }); + // Skip entries with empty URL — they are malformed and would silently + // produce tags without a resolvable image. + if url.is_empty() { + continue; + } + // Normalize to lowercase so palette lookups match scan_shortcodes output. + let shortcode = raw_shortcode.to_lowercase(); + // First occurrence within this event wins; later duplicates are dropped. + if seen.insert(shortcode.clone()) { + out.push(EmojiEntry { + shortcode, + url: url.to_string(), + }); + } } out } @@ -477,6 +496,63 @@ mod tests { // ── scan_shortcodes ────────────────────────────────────────────────────── + // ── emoji_tags_of — normalization and dedup ────────────────────────────── + + #[test] + fn emoji_tags_of_normalizes_uppercase_shortcode_to_lowercase() { + // Relay stores the original case; scanner always lowercases; so a + // stored "WAVE" must map to "wave" for resolution to work. + let event = serde_json::json!({ + "created_at": 100, + "tags": [ + ["emoji", "WAVE", "https://example.com/wave.png"], + ["emoji", "SweatBlob", "https://example.com/sweatblob.gif"], + ] + }); + let entries = emoji_tags_of(&event); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].shortcode, "wave"); + assert_eq!(entries[1].shortcode, "sweatblob"); + } + + #[test] + fn emoji_tags_of_skips_empty_url() { + // An entry with a missing or empty URL is malformed; it must be + // dropped so palette lookups never return an unusable image URL. + let event = serde_json::json!({ + "created_at": 100, + "tags": [ + ["emoji", "good", "https://example.com/good.png"], + ["emoji", "bad", ""], + ["emoji", "alsobad"], // missing url field entirely + ] + }); + let entries = emoji_tags_of(&event); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].shortcode, "good"); + } + + #[test] + fn emoji_tags_of_first_occurrence_wins_within_event() { + // Within one event the first occurrence of a (normalized) shortcode + // wins; a later duplicate tag for the same shortcode is dropped. + let event = serde_json::json!({ + "created_at": 100, + "tags": [ + ["emoji", "wave", "https://example.com/wave-first.png"], + ["emoji", "wave", "https://example.com/wave-second.png"], + ["emoji", "WAVE", "https://example.com/wave-uppercase.png"], + ] + }); + let entries = emoji_tags_of(&event); + assert_eq!( + entries.len(), + 1, + "all three normalize to 'wave'; only first kept" + ); + assert_eq!(entries[0].url, "https://example.com/wave-first.png"); + } + #[test] fn scan_finds_basic_shortcode() { assert_eq!(scan_shortcodes(":wave:"), vec!["wave"]); diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 6e4cd2b4ea8..f80f928d316 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -705,10 +705,22 @@ pub async fn cmd_send_message( // 45003) do not accept emoji_tags, so resolving early would pay // the relay query and immediately discard the result. // The fetch is skipped entirely when content has no `:`, keeping - // plain sends at zero extra RTTs. + // plain sends at zero extra RTTs. Palette resolution is + // decorative enrichment — a fetch or parse failure must not block + // delivery of a valid message; on error, degrade to no emoji tags + // and log a diagnostic to stderr. let emoji_tags = if final_content.contains(':') { - crate::commands::emoji::resolve_emoji_tags_for_content(client, &final_content) - .await? + match crate::commands::emoji::resolve_emoji_tags_for_content(client, &final_content) + .await + { + Ok(tags) => tags, + Err(e) => { + eprintln!( + "warning: emoji palette fetch failed ({e}); sending without emoji tags" + ); + Vec::new() + } + } } else { Vec::new() }; @@ -1802,4 +1814,78 @@ mod tests { "no-colon content must produce no emoji tags, got: {emoji_tags:?}" ); } + + #[tokio::test] + async fn cmd_send_message_succeeds_when_palette_query_errors() { + // Palette enrichment is decorative — a 500 from the `/query` endpoint + // must not abort delivery; the message must still be sent with zero + // emoji tags, and a diagnostic must be emitted to stderr. + + // Fake relay: `/query` returns 500, `/events` accepts and captures. + let captured_event: StdArc>> = + StdArc::new(std::sync::Mutex::new(None)); + let cap = captured_event.clone(); + let app = AxumRouter::new() + .route( + "/query", + axum_post(|_headers: AxumHeaderMap, _req: AxumBytes| async move { + ( + AxumStatusCode::INTERNAL_SERVER_ERROR, + [("content-type", "application/json")], + r#"{"error":"unavailable"}"#, + ) + }), + ) + .route( + "/events", + axum_post(move |_headers: AxumHeaderMap, body: AxumBytes| { + let cap = cap.clone(); + async move { + let body_str = String::from_utf8_lossy(&body).to_string(); + *cap.lock().unwrap() = Some(CapturedEvent { body: body_str }); + ( + AxumStatusCode::OK, + [("content-type", "application/json")], + r#"{"event_id":"fake0001","accepted":true}"#, + ) + } + }), + ); + + 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() }); + let url = format!("http://{addr}"); + let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + + // Must not return Err — a palette failure is a soft warning. + cmd_send_message(&client, send_params(":wave: message with emoji candidate")) + .await + .expect("send must succeed even when palette query returns 500"); + + // Submitted event must have zero emoji tags (fallback to empty). + let raw = captured_event.lock().unwrap(); + let raw = raw.as_ref().expect("event must have been submitted"); + let event: serde_json::Value = serde_json::from_str(&raw.body).unwrap(); + let tags: Vec> = event["tags"] + .as_array() + .unwrap() + .iter() + .map(|t| { + t.as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap_or("").to_string()) + .collect() + }) + .collect(); + let emoji_tags: Vec<&Vec> = tags + .iter() + .filter(|t| t.first().map(|s| s.as_str()) == Some("emoji")) + .collect(); + assert!( + emoji_tags.is_empty(), + "palette-error fallback must produce no emoji tags, got: {emoji_tags:?}" + ); + } } From e81bd383489824b22cb3831134b8a8843513ecb1 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 18:53:08 -0400 Subject: [PATCH 9/9] fix(cli): use SDK normalizer in emoji_tags_of for full canonicalization The previous fix applied only to_lowercase() to palette shortcodes. The relay validates shortcodes via normalize_custom_emoji_shortcode but stores the original signed tag, so a relay-valid key like " :WAVE: " (whitespace + colons + uppercase) would still fail resolution against scan_shortcodes output, which always emits lowercase undecorated strings. Replace the bare to_lowercase() with buzz_sdk::normalize_custom_emoji_shortcode: trim whitespace/colons, validate charset/length, lowercase. Entries that fail normalization (malformed tags not caught at ingest) are skipped. Regression test: resolve_tags_non_canonical_palette_key_resolves drives production resolve_emoji_tags_for_content through a fake palette whose entry carries the relay-valid key " :WAVE: " and asserts that content containing :wave: resolves to the canonical emoji tag. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-cli/src/commands/emoji.rs | 58 +++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/crates/buzz-cli/src/commands/emoji.rs b/crates/buzz-cli/src/commands/emoji.rs index 0462712575d..32aef2f9273 100644 --- a/crates/buzz-cli/src/commands/emoji.rs +++ b/crates/buzz-cli/src/commands/emoji.rs @@ -18,9 +18,12 @@ struct EmojiEntry { /// Parse `["emoji", shortcode, url]` tags from one event into entries. /// /// Mirrors desktop `customEmojiFromTags` (`desktop/src/shared/api/customEmoji.ts`): -/// - Shortcode is normalized to lowercase (relay stores original case; scanner -/// always lowercases, so an upper-case stored key would never resolve without -/// this step). +/// - Shortcode is canonicalized via `buzz_sdk::normalize_custom_emoji_shortcode` +/// (trim whitespace/colons, validate charset/length, lowercase). The relay +/// validates with the same fn at ingest but stores the original signed tag, +/// so a relay-valid stored key like `" :WAVE: "` must be normalized here or +/// it will never resolve against `scan_shortcodes` output. Malformed tags +/// (where normalization returns `Err`) are skipped. /// - Entries with a missing or empty URL are skipped. /// - Within one event the first occurrence of a normalized shortcode wins; /// later duplicates are dropped. @@ -48,8 +51,15 @@ fn emoji_tags_of(event: &serde_json::Value) -> Vec { if url.is_empty() { continue; } - // Normalize to lowercase so palette lookups match scan_shortcodes output. - let shortcode = raw_shortcode.to_lowercase(); + // Canonicalize via the SDK normalizer: trim whitespace/colons, validate + // charset/length, lowercase. Relay validates with this same fn at + // ingest but stores the original tag — so a relay-valid key like + // " :WAVE: " must map to "wave" here or it will never resolve against + // scan_shortcodes output. Skip on Err (malformed tag). + let shortcode = match buzz_sdk::normalize_custom_emoji_shortcode(raw_shortcode) { + Ok(s) => s, + Err(_) => continue, + }; // First occurrence within this event wins; later duplicates are dropped. if seen.insert(shortcode.clone()) { out.push(EmojiEntry { @@ -501,12 +511,13 @@ mod tests { #[test] fn emoji_tags_of_normalizes_uppercase_shortcode_to_lowercase() { // Relay stores the original case; scanner always lowercases; so a - // stored "WAVE" must map to "wave" for resolution to work. + // stored "WAVE" must map to "wave" for resolution to work. Also + // covers relay-valid keys with surrounding whitespace/colons. let event = serde_json::json!({ "created_at": 100, "tags": [ ["emoji", "WAVE", "https://example.com/wave.png"], - ["emoji", "SweatBlob", "https://example.com/sweatblob.gif"], + ["emoji", " :SweatBlob: ", "https://example.com/sweatblob.gif"], ] }); let entries = emoji_tags_of(&event); @@ -763,4 +774,37 @@ mod tests { "must query palette once even when no shortcodes resolve" ); } + + #[tokio::test] + async fn resolve_tags_non_canonical_palette_key_resolves() { + // The relay validates shortcodes via normalize_custom_emoji_shortcode but + // stores the original signed tag. A relay-valid stored key like + // " :WAVE: " must resolve when content contains `:wave:`. + // This is the production-resolver regression that proves emoji_tags_of + // uses the SDK normalizer rather than a plain lowercase conversion. + let non_canonical_palette = serde_json::json!([{ + "created_at": 100, + "tags": [ + ["d", "buzz:custom-emoji"], + // Relay-valid but non-canonical: whitespace + surrounding colons + uppercase. + ["emoji", " :WAVE: ", "https://cdn.example.com/wave.png"], + ] + }]) + .to_string(); + let (url, _calls) = fake_query_server(non_canonical_palette).await; + let client = test_client(&url); + let tags = resolve_emoji_tags_for_content(&client, "hello :wave:") + .await + .unwrap(); + assert_eq!( + tags.len(), + 1, + "non-canonical palette key must resolve; got tags: {tags:?}" + ); + assert_eq!( + tags[0], + vec!["emoji", "wave", "https://cdn.example.com/wave.png"], + "resolved tag must use the canonical lowercase shortcode" + ); + } }