diff --git a/desktop/src-tauri/src/commands/message_converters.rs b/desktop/src-tauri/src/commands/message_converters.rs new file mode 100644 index 000000000..6123d0883 --- /dev/null +++ b/desktop/src-tauri/src/commands/message_converters.rs @@ -0,0 +1,96 @@ +//! Event → UI-model converters shared by the message feed commands. + +use crate::models::{FeedItemInfo, ForumMessageInfo, ForumThreadReplyInfo, ThreadSummary}; + +pub(super) fn channel_id_from_tags(ev: &nostr::Event) -> Option { + ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "h" { + Some(s[1].clone()) + } else { + None + } + }) +} + +pub(super) fn tags_to_vec(ev: &nostr::Event) -> Vec> { + ev.tags.iter().map(|t| t.as_slice().to_vec()).collect() +} + +pub(super) fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo { + let channel_id = channel_id_from_tags(ev); + FeedItemInfo { + id: ev.id.to_hex(), + kind: ev.kind.as_u16() as u32, + pubkey: ev.pubkey.to_hex(), + content: ev.content.clone(), + created_at: ev.created_at.as_secs(), + channel_id, + channel_name: String::new(), + channel_type: None, + tags: tags_to_vec(ev), + category: category.to_string(), + } +} + +pub(super) fn forum_message_from_event(ev: &nostr::Event, channel_id: &str) -> ForumMessageInfo { + ForumMessageInfo { + event_id: ev.id.to_hex(), + pubkey: ev.pubkey.to_hex(), + content: ev.content.clone(), + kind: ev.kind.as_u16() as u32, + created_at: ev.created_at.as_secs() as i64, + channel_id: channel_id.to_string(), + tags: tags_to_vec(ev), + thread_summary: Some(ThreadSummary { + reply_count: 0, + descendant_count: 0, + last_reply_at: None, + participants: Vec::new(), + }), + reactions: serde_json::Value::Null, + } +} + +pub(super) fn forum_reply_from_event( + ev: &nostr::Event, + channel_id: &str, + root_event_id: &str, +) -> ForumThreadReplyInfo { + // Walk e-tags for NIP-10 parent/root markers. + let (mut parent_id, mut explicit_root) = (None, None); + for t in ev.tags.iter() { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "e" { + match s.get(3).map(|x| x.as_str()) { + Some("root") => explicit_root = Some(s[1].clone()), + Some("reply") => parent_id = Some(s[1].clone()), + _ => { + if parent_id.is_none() { + parent_id = Some(s[1].clone()); + } + } + } + } + } + let parent = parent_id + .clone() + .unwrap_or_else(|| root_event_id.to_string()); + let root = explicit_root.unwrap_or_else(|| root_event_id.to_string()); + let depth = if parent == root { 1 } else { 2 }; + + ForumThreadReplyInfo { + event_id: ev.id.to_hex(), + pubkey: ev.pubkey.to_hex(), + content: ev.content.clone(), + kind: ev.kind.as_u16() as u32, + created_at: ev.created_at.as_secs() as i64, + channel_id: channel_id.to_string(), + tags: tags_to_vec(ev), + parent_event_id: Some(parent), + root_event_id: Some(root), + depth, + broadcast: false, + reactions: serde_json::Value::Null, + } +} diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 3a057eaa1..176555216 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -1,6 +1,10 @@ use nostr::{Event, EventId, Keys, PublicKey}; use tauri::{AppHandle, State}; +use super::message_converters::{ + feed_item_from_event, forum_message_from_event, forum_reply_from_event, +}; + use crate::{ app_state::AppState, events, @@ -8,7 +12,7 @@ use crate::{ models::{ FeedItemInfo, FeedMeta, FeedResponse, FeedSections, ForumMessageInfo, ForumPostsResponse, ForumThreadReplyInfo, ForumThreadResponse, SearchResponse, SendChannelMessageResponse, - ThreadRepliesResponse, ThreadSummary, + ThreadRepliesResponse, }, nostr_convert, relay::{query_relay, submit_event, submit_event_with_keys}, @@ -484,6 +488,7 @@ pub async fn send_channel_message( mention_tags: Option>>, mention_pubkeys: Option>, kind: Option, + client_tags: Option>>, state: State<'_, AppState>, ) -> Result { let channel_uuid = uuid::Uuid::parse_str(&channel_id) @@ -493,6 +498,7 @@ pub async fn send_channel_message( let media = media_tags.unwrap_or_default(); let emoji = emoji_tags.unwrap_or_default(); let mention_refs_only = mention_tags.unwrap_or_default(); + let client = client_tags.unwrap_or_default(); let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE); let mut resolved_root: Option = None; @@ -529,7 +535,7 @@ pub async fn send_channel_message( } None => None, }; - events::build_message( + events::build_message_with_client_tags( channel_uuid, content.trim(), thread_ref.as_ref(), @@ -537,6 +543,7 @@ pub async fn send_channel_message( &media, &emoji, &mention_refs_only, + &client, )? } }; @@ -986,96 +993,3 @@ pub async fn delete_message( } // ── Local helpers ─────────────────────────────────────────────────────────── - -fn channel_id_from_tags(ev: &nostr::Event) -> Option { - ev.tags.iter().find_map(|t| { - let s = t.as_slice(); - if s.len() >= 2 && s[0] == "h" { - Some(s[1].clone()) - } else { - None - } - }) -} - -fn tags_to_vec(ev: &nostr::Event) -> Vec> { - ev.tags.iter().map(|t| t.as_slice().to_vec()).collect() -} - -fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo { - let channel_id = channel_id_from_tags(ev); - FeedItemInfo { - id: ev.id.to_hex(), - kind: ev.kind.as_u16() as u32, - pubkey: ev.pubkey.to_hex(), - content: ev.content.clone(), - created_at: ev.created_at.as_secs(), - channel_id, - channel_name: String::new(), - channel_type: None, - tags: tags_to_vec(ev), - category: category.to_string(), - } -} - -fn forum_message_from_event(ev: &nostr::Event, channel_id: &str) -> ForumMessageInfo { - ForumMessageInfo { - event_id: ev.id.to_hex(), - pubkey: ev.pubkey.to_hex(), - content: ev.content.clone(), - kind: ev.kind.as_u16() as u32, - created_at: ev.created_at.as_secs() as i64, - channel_id: channel_id.to_string(), - tags: tags_to_vec(ev), - thread_summary: Some(ThreadSummary { - reply_count: 0, - descendant_count: 0, - last_reply_at: None, - participants: Vec::new(), - }), - reactions: serde_json::Value::Null, - } -} - -fn forum_reply_from_event( - ev: &nostr::Event, - channel_id: &str, - root_event_id: &str, -) -> ForumThreadReplyInfo { - // Walk e-tags for NIP-10 parent/root markers. - let (mut parent_id, mut explicit_root) = (None, None); - for t in ev.tags.iter() { - let s = t.as_slice(); - if s.len() >= 2 && s[0] == "e" { - match s.get(3).map(|x| x.as_str()) { - Some("root") => explicit_root = Some(s[1].clone()), - Some("reply") => parent_id = Some(s[1].clone()), - _ => { - if parent_id.is_none() { - parent_id = Some(s[1].clone()); - } - } - } - } - } - let parent = parent_id - .clone() - .unwrap_or_else(|| root_event_id.to_string()); - let root = explicit_root.unwrap_or_else(|| root_event_id.to_string()); - let depth = if parent == root { 1 } else { 2 }; - - ForumThreadReplyInfo { - event_id: ev.id.to_hex(), - pubkey: ev.pubkey.to_hex(), - content: ev.content.clone(), - kind: ev.kind.as_u16() as u32, - created_at: ev.created_at.as_secs() as i64, - channel_id: channel_id.to_string(), - tags: tags_to_vec(ev), - parent_event_id: Some(parent), - root_event_id: Some(root), - depth, - broadcast: false, - reactions: serde_json::Value::Null, - } -} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 9b6c8a9de..05fd83959 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -22,6 +22,7 @@ mod media_download; mod media_transcode; #[cfg(feature = "mesh-llm")] mod mesh_llm; +mod message_converters; mod messages; mod notifications; mod observer_archive; diff --git a/desktop/src/features/chats/lib/chatWorkAutomation.ts b/desktop/src/features/chats/lib/chatWorkAutomation.ts index ee02f9b9f..4470275d6 100644 --- a/desktop/src/features/chats/lib/chatWorkAutomation.ts +++ b/desktop/src/features/chats/lib/chatWorkAutomation.ts @@ -13,10 +13,9 @@ const STORAGE_EVENT = "buzz:chat-work-automation-changed"; * the chat timeline renders only its activity — not the message bubble — so * armed automation feels ambient instead of ventriloquized. */ -export const CHAT_AUTOMATION_TAG: [string, string] = [ - "automation", - "work-panel", -]; +// Rides the whitelisted ["client", ...] marker-tag channel: the imeta-only +// media path rejects any other prefix and fails the whole send. +export const CHAT_AUTOMATION_TAG: [string, string] = ["client", "automation"]; /** Tag for one automation prompt, carrying its kind for the marker row. */ export function chatAutomationTag(kind: "ci" | "comments"): string[] { diff --git a/desktop/src/features/chats/ui/ChatConversationRows.tsx b/desktop/src/features/chats/ui/ChatConversationRows.tsx index 96d6af663..858890671 100644 --- a/desktop/src/features/chats/ui/ChatConversationRows.tsx +++ b/desktop/src/features/chats/ui/ChatConversationRows.tsx @@ -182,7 +182,9 @@ export function ChatAutomationRow({ agentName: string; event: RelayEvent; }) { - const tag = event.tags.find((candidate) => candidate[0] === "automation"); + const tag = event.tags.find( + (candidate) => candidate[0] === "client" && candidate[1] === "automation", + ); return ( diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 341cdacf8..bc8e7f0e8 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -506,12 +506,18 @@ export function useSendMessageMutation( mediaTags: imetaTags, emojiTags, mentionTags, + clientTags, } = splitOutgoingTags(mediaTags); - // Messages carrying media OR custom-emoji tags MUST go through REST so - // the relay's tag validation runs. The WebSocket path emits no extra - // tags, so emoji-only messages would otherwise lose their emoji tag. - if (parentEventId || imetaTags.length > 0 || emojiTags.length > 0) { + // Messages carrying media, custom-emoji, or client marker tags MUST go + // through REST so the tag-validating builder runs. The WebSocket path + // emits no extra tags, so those sends would otherwise lose their tags. + if ( + parentEventId || + imetaTags.length > 0 || + emojiTags.length > 0 || + clientTags.length > 0 + ) { const cachedMessages = queryClient.getQueryData( channelMessagesKey(effectiveChannel.id), @@ -525,6 +531,7 @@ export function useSendMessageMutation( undefined, emojiTags, mentionTags, + clientTags, ); // Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji. diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs index 1bbbdce9f..78f661cec 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -531,14 +531,23 @@ const MENTION_REF = [ "1111111111111111111111111111111111111111111111111111111111111111", ]; -test("splitOutgoingTags: undefined input yields three empty arrays", () => { +test("splitOutgoingTags: undefined input yields four empty arrays", () => { assert.deepEqual(splitOutgoingTags(undefined), { mediaTags: [], emojiTags: [], mentionTags: [], + clientTags: [], }); }); +test("splitOutgoingTags: routes client marker tags off the media channel", () => { + const { mediaTags, clientTags } = splitOutgoingTags([ + ["client", "automation", "ci"], + ]); + assert.deepEqual(mediaTags, []); + assert.deepEqual(clientTags, [["client", "automation", "ci"]]); +}); + test("splitOutgoingTags: separates emoji tags from imeta tags", () => { const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([ IMETA, diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index 10516555d..873cc6fe4 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -310,18 +310,25 @@ export function splitOutgoingTags(tags: string[][] | undefined): { mediaTags: string[][]; emojiTags: string[][]; mentionTags: string[][]; + clientTags: string[][]; } { const mediaTags: string[][] = []; const emojiTags: string[][] = []; const mentionTags: string[][] = []; + const clientTags: string[][] = []; for (const tag of tags ?? []) { if (tag[0] === "emoji") { emojiTags.push(tag); } else if (tag[0] === "mention") { mentionTags.push(tag); + } else if (tag[0] === "client") { + // Whitelisted client marker tags (e.g. automation prompts) — these + // must NOT ride the imeta-only media channel, whose builder rejects + // any non-imeta prefix and fails the whole send. + clientTags.push(tag); } else { mediaTags.push(tag); } } - return { mediaTags, emojiTags, mentionTags }; + return { mediaTags, emojiTags, mentionTags, clientTags }; } diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index f181d04fc..016f1647d 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -837,6 +837,7 @@ export async function sendChannelMessage( kind?: number, emojiTags?: string[][], mentionTags?: string[][], + clientTags?: string[][], ): Promise { const response = await invokeTauri( "send_channel_message", @@ -849,6 +850,7 @@ export async function sendChannelMessage( mentionTags: mentionTags ?? null, mentionPubkeys: mentionPubkeys ?? null, kind: kind ?? null, + clientTags: clientTags ?? null, }, ); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1b4f02d4b..c968d9778 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -7172,6 +7172,7 @@ async function handleSendChannelMessage( mentionPubkeys?: string[]; mediaTags?: string[][] | null; emojiTags?: string[][] | null; + clientTags?: string[][] | null; }, config: E2eConfig | undefined, ): Promise { @@ -7187,12 +7188,25 @@ async function handleSendChannelMessage( // event; mirror that here so attachment renderers (FileCard, images, video) // have the imeta tags they key on. `null`/empty → no extra tags. const mediaTags = args.mediaTags ?? []; + // Mirror the Rust builder's imeta-only gate: a non-imeta tag on the media + // channel fails the real send, and the mock accepting it is exactly how a + // broken-live tag route stayed green in e2e. + for (const tag of mediaTags) { + if (tag[0] !== "imeta") { + throw new Error(`media tags must use 'imeta' prefix (got ${tag[0]})`); + } + } // NIP-30 custom-emoji tags ride their own validated arg server-side; the // relay echoes them back on the stored event too, so mirror that here so the // emoji renderer keeps resolving `:shortcode:` after the round-trip. const emojiTags = args.emojiTags ?? []; - // Both kinds end up on the stored event's tag set, just like the real relay. - const extraTags = [...mediaTags, ...emojiTags]; + // Whitelisted ["client", ...] marker tags (automation prompts) — echoed + // back on the stored event like the real builder. + const clientTags = (args.clientTags ?? []).filter( + (tag) => tag[0] === "client", + ); + // All kinds end up on the stored event's tag set, just like the real relay. + const extraTags = [...mediaTags, ...emojiTags, ...clientTags]; const identity = getIdentity(config); if (!identity) { const createdAt = Math.floor(Date.now() / 1000); diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index 133a39c78..116d84bf2 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -214,7 +214,7 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { window as Window & { __BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ command: string; - payload?: { content?: string; mediaTags?: string[][] }; + payload?: { content?: string; clientTags?: string[][] }; }>; } ).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [] @@ -222,8 +222,8 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { (entry) => entry.command === "send_channel_message" && entry.payload?.content?.includes("unanswered review comments") && - entry.payload?.mediaTags?.some( - (tag) => tag[0] === "automation" && tag[1] === "work-panel", + entry.payload?.clientTags?.some( + (tag) => tag[0] === "client" && tag[1] === "automation", ), ), ),