Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion crates/marmot-app/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down Expand Up @@ -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?;
Expand Down
122 changes: 117 additions & 5 deletions crates/marmot-app/src/messages/intents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,31 @@ fn mention_p_tags(content: &str) -> Vec<Vec<String>> {
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", <value>]` 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<String>]) -> Vec<Vec<String>> {
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.
Expand All @@ -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", <value>]` 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<Vec<String>>,
},
Reaction {
target_message_id: String,
Expand All @@ -170,6 +202,8 @@ pub(crate) enum AppMessageIntent {
Reply {
target_message_id: String,
text: String,
/// See [`AppMessageIntent::Chat::extra_tags`].
extra_tags: Vec<Vec<String>>,
},
Edit {
target_message_id: String,
Expand Down Expand Up @@ -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,
Expand All @@ -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() {
Expand All @@ -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 {
Expand Down Expand Up @@ -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();
Expand All @@ -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!(
Expand Down
50 changes: 50 additions & 0 deletions crates/marmot-app/src/runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>,
extra_tags: Vec<Vec<String>>,
) -> Result<SendSummary, AppError> {
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,
Expand Down Expand Up @@ -1584,6 +1618,21 @@ impl MarmotAppRuntime {
group_id: &GroupId,
target_message_id: &str,
text: &str,
) -> Result<SendSummary, AppError> {
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<Vec<String>>,
) -> Result<SendSummary, AppError> {
let summary = self
.accounts
Expand All @@ -1593,6 +1642,7 @@ impl MarmotAppRuntime {
AppMessageIntent::Reply {
target_message_id: target_message_id.to_owned(),
text: text.to_owned(),
extra_tags,
},
)
.await?;
Expand Down
5 changes: 5 additions & 0 deletions crates/marmot-app/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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.
Expand All @@ -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]);
Expand Down
Loading