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..32aef2f9273 100644 --- a/crates/buzz-cli/src/commands/emoji.rs +++ b/crates/buzz-cli/src/commands/emoji.rs @@ -16,10 +16,22 @@ struct EmojiEntry { } /// Parse `["emoji", shortcode, url]` tags from one event into entries. +/// +/// Mirrors desktop `customEmojiFromTags` (`desktop/src/shared/api/customEmoji.ts`): +/// - 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. 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 +40,33 @@ 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; + } + // 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 { + shortcode, + url: url.to_string(), + }); + } } out } @@ -308,6 +337,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 +503,308 @@ mod tests { assert_eq!(emojis[0].shortcode, "zort"); assert_eq!(emojis[0].url, "https://example.com/zort.png"); } + + // ── 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. 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"], + ] + }); + 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"]); + } + + #[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"]); + } + + // ── 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" + ); + } + + #[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" + ); + } } diff --git a/crates/buzz-cli/src/commands/gifs.rs b/crates/buzz-cli/src/commands/gifs.rs new file mode 100644 index 00000000000..0352d2cdb37 --- /dev/null +++ b/crates/buzz-cli/src/commands/gifs.rs @@ -0,0 +1,1035 @@ +//! 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"; + +// --------------------------------------------------------------------------- +// 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. +/// 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 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| { + let code: String = l.split('.').next().unwrap_or("").chars().take(5).collect(); + if code.len() >= 2 { + Some(code) + } else { + None + } + }) + .unwrap_or_else(|| "en_US".to_string()) +} + +// --------------------------------------------------------------------------- +// NIP-11 descriptor resolution +// --------------------------------------------------------------------------- + +/// Parse the `gif` descriptor from a decoded NIP-11 JSON document. +/// +/// 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> { + // 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.contains(&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)" + ))); + } + + // Gate 3: both paths must be present and pass the safe-path check. + 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(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)) +} + +/// 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 +// --------------------------------------------------------------------------- + +/// 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 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 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(), + 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": cid, + "locale": locale, + }); + let raw = client.post_json_authed(&search_path, &body).await?; + normalize_gif_response(&raw) +} + +/// `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 cid = customer_id( + client.keys().secret_key().as_secret_bytes(), + client.relay_url(), + ); + + let body = serde_json::json!({ + "slug": slug, + "customer_id": cid, + }); + // 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, + } +} + +// --------------------------------------------------------------------------- +// 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 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())); + assert_eq!(id, customer_id(&sk, "https://relay.example")); + } + + #[test] + 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 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()); + } + + // ----------------------------------------------------------------------- + // parse_gif_descriptor_info — production gate logic, no I/O + // ----------------------------------------------------------------------- + + #[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" } + }); + let err = parse_gif_descriptor_info(&info).unwrap_err(); + assert!( + err.to_string().contains("buzz-gif"), + "error must mention buzz-gif, got: {err}" + ); + } + + #[test] + 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 err = parse_gif_descriptor_info(&info).unwrap_err(); + assert!( + err.to_string().contains("tenor"), + "error must mention the bad provider, got: {err}" + ); + } + + #[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" } + }); + let err = parse_gif_descriptor_info(&info).unwrap_err(); + assert!( + err.to_string().contains("search path"), + "error must mention search path, got: {err}" + ); + } + + #[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" } + }); + let err = parse_gif_descriptor_info(&info).unwrap_err(); + assert!( + err.to_string().contains("share path"), + "error must mention share path, got: {err}" + ); + } + + #[test] + fn descriptor_valid_passes() { + let info = serde_json::json!({ + "supported_extensions": ["buzz-gif"], + "gif": { "provider": "klipy", "search": "/gifs/search", "share": "/gifs/share" } + }); + let (search, share) = parse_gif_descriptor_info(&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 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)] + struct Captured { + path: String, + auth_header: String, + auth_tag_header: String, + body: String, + } + + /// 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, + 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")], + 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(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (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() + } + + /// 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() + } + + // ── 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, 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 == ALT_SEARCH_PATH) + .expect("POST must arrive at the NIP-11-advertised path"); + assert!( + call.auth_header.starts_with("Nostr "), + "Authorization must be a NIP-98 Nostr token, got: {:?}", + call.auth_header + ); + 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 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); + + cmd_share(&client, "my-gif-slug").await.unwrap(); + + let calls = captured.lock().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 a 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" + ); + } + + // ── 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); + 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":"/x/search-alt","share":"/x/share-alt"}}"#, + ) + }), + ); + 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}" + ); + } +} diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 9f41fbf751c..f80f928d316 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -698,15 +698,43 @@ 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, - ) - .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. 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(':') { + 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() + }; + 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)" @@ -1056,11 +1084,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, @@ -1570,4 +1598,294 @@ 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:?}" + ); + } + + #[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:?}" + ); + } } 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] 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();