diff --git a/crates/marmot-app/src/client/mod.rs b/crates/marmot-app/src/client/mod.rs index 82136eae..3f9f907f 100644 --- a/crates/marmot-app/src/client/mod.rs +++ b/crates/marmot-app/src/client/mod.rs @@ -1028,7 +1028,10 @@ impl AppClient { let (_event, summary) = self .send_app_event_with_local_projection( group_id, - AppMessageIntent::Chat { content }, + AppMessageIntent::Chat { + content, + extra_tags: Vec::new(), + }, on_local_projection, ) .await?; @@ -1276,6 +1279,7 @@ impl AppClient { AppMessageIntent::Reply { target_message_id: target_message_id.to_owned(), text: text.to_owned(), + extra_tags: Vec::new(), }, ) .await?; diff --git a/crates/marmot-app/src/messages/intents.rs b/crates/marmot-app/src/messages/intents.rs index 7eee63ee..9e10a00b 100644 --- a/crates/marmot-app/src/messages/intents.rs +++ b/crates/marmot-app/src/messages/intents.rs @@ -146,6 +146,31 @@ fn mention_p_tags(content: &str) -> Vec> { tags } +/// The one host-defined `extra_tags` tag name Marmot will carry today: a +/// message effect, e.g. `["effect", "fire"]`. +const EFFECT_TAG: &str = "effect"; + +/// Filter host-supplied `extra_tags` down to the tags Marmot is willing to +/// carry on an outgoing chat/reply event. +/// +/// `extra_tags` is a public authoring escape hatch, but a kind-9 event's tags +/// are load-bearing on the receive side: `e`/`q` mark a row as a reply, `imeta` +/// marks it as media, `stream*` marks it as an agent-stream final. Carrying +/// arbitrary tags verbatim would let a plain text send be projected as a +/// reply/media/stream message, so we whitelist rather than blacklist: only a +/// well-formed `["effect", ]` tag survives. Everything else — reserved +/// protocol tags, unknown names, and malformed effect tags — is dropped. When +/// the set of app-defined tags grows, widen this whitelist deliberately. +fn sanitized_extra_tags(extra_tags: &[Vec]) -> Vec> { + extra_tags + .iter() + .filter(|tag| { + matches!(tag.as_slice(), [name, value] if name == EFFECT_TAG && !value.is_empty()) + }) + .cloned() + .collect() +} + /// Value of the `stream-type` tag on an agent text stream start event. const STREAM_TYPE_TEXT: &str = "text"; /// Value of the `route` tag on a brokered QUIC agent text stream start event. @@ -159,6 +184,13 @@ const STREAM_FINAL_KIND_CHAT: &str = "9"; pub(crate) enum AppMessageIntent { Chat { content: String, + /// Extra application-defined tags appended to the kind-9 event (after the + /// protocol-derived ones). Lets host apps carry out-of-band metadata as a + /// real nostr tag instead of smuggling it into the visible body. Only a + /// well-formed `["effect", ]` tag is carried today; every other + /// tag (reserved protocol tags included) is dropped by + /// [`sanitized_extra_tags`]. Empty for a plain chat. + extra_tags: Vec>, }, Reaction { target_message_id: String, @@ -170,6 +202,8 @@ pub(crate) enum AppMessageIntent { Reply { target_message_id: String, text: String, + /// See [`AppMessageIntent::Chat::extra_tags`]. + extra_tags: Vec>, }, Edit { target_message_id: String, @@ -241,11 +275,14 @@ pub(crate) fn build_inner_event( ) }; match intent { - AppMessageIntent::Chat { content } => Ok(event( - MARMOT_APP_EVENT_KIND_CHAT, - mention_p_tags(content), - content.clone(), - )), + AppMessageIntent::Chat { + content, + extra_tags, + } => { + let mut tags = mention_p_tags(content); + tags.extend(sanitized_extra_tags(extra_tags)); + Ok(event(MARMOT_APP_EVENT_KIND_CHAT, tags, content.clone())) + } AppMessageIntent::Reaction { target_message_id, emoji, @@ -265,6 +302,7 @@ pub(crate) fn build_inner_event( AppMessageIntent::Reply { target_message_id, text, + extra_tags, } => { validate_message_ref(target_message_id)?; if text.trim().is_empty() { @@ -277,6 +315,7 @@ pub(crate) fn build_inner_event( vec![QUOTE_REF_TAG.to_owned(), target_message_id.clone()], ]; tags.extend(mention_p_tags(text)); + tags.extend(sanitized_extra_tags(extra_tags)); Ok(event(MARMOT_APP_EVENT_KIND_CHAT, tags, text.clone())) } AppMessageIntent::Edit { @@ -675,11 +714,83 @@ mod mention_tests { let npub = npub_for_account_id(&hex).unwrap(); let intent = AppMessageIntent::Chat { content: format!("yo nostr:{npub}"), + extra_tags: Vec::new(), }; let event = build_inner_event(&intent, &valid_pubkey_hex(), 0).unwrap(); assert!(event.tags.contains(&vec!["p".to_owned(), hex])); } + #[test] + fn chat_intent_carries_effect_tag_and_drops_everything_else() { + let intent = AppMessageIntent::Chat { + content: "hello".to_owned(), + extra_tags: vec![ + vec!["effect".to_owned(), "fire".to_owned()], + Vec::new(), // malformed — dropped + vec!["effect".to_owned()], // no value — dropped + vec!["effect".to_owned(), String::new()], // empty value — dropped + vec!["effect".to_owned(), "fire".to_owned(), "x".to_owned()], // trailing field — dropped + vec!["e".to_owned(), "ff".repeat(32)], // reserved reply ref — dropped + vec!["imeta".to_owned(), "url ...".to_owned()], // reserved media — dropped + vec!["stream".to_owned(), "x".to_owned()], // reserved stream — dropped + ], + }; + let event = build_inner_event(&intent, &valid_pubkey_hex(), 0).unwrap(); + assert_eq!(event.content, "hello", "body must stay clean"); + assert!( + event + .tags + .contains(&vec!["effect".to_owned(), "fire".to_owned()]) + ); + assert!( + event + .tags + .iter() + .all(|t| t.first().map(String::as_str) != Some("e")), + "reserved 'e' tag must not be injectable via extra_tags" + ); + assert!( + event + .tags + .iter() + .all(|t| t.first().map(String::as_str) != Some("imeta")), + "reserved 'imeta' tag must not be injectable via extra_tags" + ); + assert!( + event + .tags + .iter() + .all(|t| t.first().map(String::as_str) != Some("stream")), + "reserved 'stream' tag must not be injectable via extra_tags" + ); + // Only the one well-formed effect tag rode along beside the derived p-tags. + assert_eq!( + event + .tags + .iter() + .filter(|t| t.first().map(String::as_str) == Some("effect")) + .count(), + 1, + "only the well-formed effect tag survives" + ); + } + + #[test] + fn reply_intent_appends_extra_tags() { + let target = "ff".repeat(32); + let intent = AppMessageIntent::Reply { + target_message_id: target.clone(), + text: "re".to_owned(), + extra_tags: vec![vec!["effect".to_owned(), "love".to_owned()]], + }; + let event = build_inner_event(&intent, &valid_pubkey_hex(), 0).unwrap(); + assert!( + event + .tags + .contains(&vec!["effect".to_owned(), "love".to_owned()]) + ); + } + #[test] fn reply_intent_keeps_e_q_and_adds_mention_p_tag() { let hex = valid_pubkey_hex(); @@ -688,6 +799,7 @@ mod mention_tests { let intent = AppMessageIntent::Reply { target_message_id: target.clone(), text: format!("re nostr:{npub}"), + extra_tags: Vec::new(), }; let event = build_inner_event(&intent, &valid_pubkey_hex(), 0).unwrap(); assert!( diff --git a/crates/marmot-app/src/runtime/mod.rs b/crates/marmot-app/src/runtime/mod.rs index 18d88b96..0699239d 100644 --- a/crates/marmot-app/src/runtime/mod.rs +++ b/crates/marmot-app/src/runtime/mod.rs @@ -1293,6 +1293,40 @@ impl MarmotAppRuntime { Ok(summary) } + /// Like [`send_message`](Self::send_message), but appends host-supplied + /// `extra_tags` to the kind-9 event. The body stays exactly `payload`; the + /// tags travel out of band so clients that don't understand them simply + /// ignore them and render clean text. Used to carry metadata (e.g. a + /// message-effect key) without polluting the visible message content. + pub async fn send_message_with_tags( + &self, + account_ref: &str, + group_id: &GroupId, + payload: Vec, + extra_tags: Vec>, + ) -> Result { + let content = String::from_utf8(payload).map_err(|_| { + AppError::InvalidAppMessagePayload("chat message must be valid UTF-8".into()) + })?; + let summary = self + .accounts + .send_app_event( + account_ref, + group_id, + AppMessageIntent::Chat { + content, + extra_tags, + }, + ) + .await?; + let _ = self.publish_chat_list_projection_refresh( + account_ref, + &hex::encode(group_id.as_slice()), + ChatListUpdateTrigger::NewLastMessage, + ); + Ok(summary) + } + pub async fn send_agent_activity( &self, account_ref: &str, @@ -1584,6 +1618,21 @@ impl MarmotAppRuntime { group_id: &GroupId, target_message_id: &str, text: &str, + ) -> Result { + self.reply_to_message_with_tags(account_ref, group_id, target_message_id, text, Vec::new()) + .await + } + + /// Like [`reply_to_message`](Self::reply_to_message), but appends + /// host-supplied `extra_tags` to the kind-9 reply event (alongside the + /// protocol `e`/`q` tags). See [`send_message_with_tags`](Self::send_message_with_tags). + pub async fn reply_to_message_with_tags( + &self, + account_ref: &str, + group_id: &GroupId, + target_message_id: &str, + text: &str, + extra_tags: Vec>, ) -> Result { let summary = self .accounts @@ -1593,6 +1642,7 @@ impl MarmotAppRuntime { AppMessageIntent::Reply { target_message_id: target_message_id.to_owned(), text: text.to_owned(), + extra_tags, }, ) .await?; diff --git a/crates/marmot-app/src/tests.rs b/crates/marmot-app/src/tests.rs index 95fdff5b..0e99c986 100644 --- a/crates/marmot-app/src/tests.rs +++ b/crates/marmot-app/src/tests.rs @@ -1124,6 +1124,7 @@ fn build(intent: AppMessageIntent) -> MarmotInnerEvent { fn chat_intent_builds_kind_nine_with_no_tags() { let event = build(AppMessageIntent::Chat { content: "hello".to_owned(), + extra_tags: Vec::new(), }); assert_eq!(event.kind, MARMOT_APP_EVENT_KIND_CHAT); assert_eq!(event.content, "hello"); @@ -1170,6 +1171,7 @@ fn reply_intent_builds_kind_nine_with_e_and_q_tags() { let event = build(AppMessageIntent::Reply { target_message_id: "parent".to_owned(), text: "sure".to_owned(), + extra_tags: Vec::new(), }); assert_eq!(event.kind, MARMOT_APP_EVENT_KIND_CHAT); assert_eq!(event.content, "sure"); @@ -1398,6 +1400,7 @@ fn group_system_intent_builds_kind_1210_json_payload() { fn received_event_decodes_when_id_and_sender_match() { let event = build(AppMessageIntent::Chat { content: "hi".to_owned(), + extra_tags: Vec::new(), }); let bytes = event.encode().unwrap(); let group_id = GroupId::new(vec![0x01]); @@ -1513,6 +1516,7 @@ fn received_media_message_with_malformed_reference_is_rejected() { fn received_event_with_tampered_id_is_rejected() { let mut event = build(AppMessageIntent::Chat { content: "hi".to_owned(), + extra_tags: Vec::new(), }); // Mutate the content without recomputing the id: the canonical id no // longer matches, so the strict decoder must reject it. @@ -1529,6 +1533,7 @@ fn received_event_with_tampered_id_is_rejected() { fn received_event_with_wrong_sender_is_rejected() { let event = build(AppMessageIntent::Chat { content: "hi".to_owned(), + extra_tags: Vec::new(), }); let bytes = event.encode().unwrap(); let group_id = GroupId::new(vec![0x01]);