mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix: include mention p-tags in thread replies for ACP agent delivery (#68)
This commit is contained in:
@@ -306,6 +306,10 @@ pub struct SendReplyParams {
|
||||
/// If true, the reply is also broadcast to the main channel timeline.
|
||||
#[serde(default)]
|
||||
pub broadcast_to_channel: Option<bool>,
|
||||
/// Hex-encoded pubkeys of users/agents mentioned in this reply.
|
||||
/// Required for @mention notifications to reach mention-filtered subscribers.
|
||||
#[serde(default)]
|
||||
pub mention_pubkeys: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Parameters for the `get_thread` tool.
|
||||
@@ -1416,11 +1420,14 @@ impl SproutMcpServer {
|
||||
);
|
||||
}
|
||||
|
||||
let body = serde_json::json!({
|
||||
let mut body = serde_json::json!({
|
||||
"content": p.content,
|
||||
"parent_event_id": p.parent_event_id,
|
||||
"broadcast_to_channel": p.broadcast_to_channel.unwrap_or(false),
|
||||
});
|
||||
if let Some(ref mentions) = p.mention_pubkeys {
|
||||
body["mention_pubkeys"] = serde_json::json!(mentions);
|
||||
}
|
||||
match self
|
||||
.client
|
||||
.post(&format!("/api/channels/{}/messages", p.channel_id), &body)
|
||||
|
||||
@@ -392,6 +392,48 @@ fn reactions_to_json(reactions: &[sprout_db::reaction::ReactionSummary]) -> serd
|
||||
|
||||
// ── POST /api/channels/:channel_id/messages ───────────────────────────────────
|
||||
|
||||
/// Maximum number of mention pubkeys allowed per message.
|
||||
const MAX_MENTION_PUBKEYS: usize = 50;
|
||||
|
||||
/// Validate, canonicalize, and deduplicate mention pubkeys.
|
||||
///
|
||||
/// - Lowercases each entry (Nostr pubkeys are lowercase hex by convention).
|
||||
/// - Validates each is a 64-char hex string (32-byte pubkey).
|
||||
/// - Deduplicates (including against `self_pubkey_hex`).
|
||||
/// - Rejects results exceeding [`MAX_MENTION_PUBKEYS`] **after** normalization.
|
||||
///
|
||||
/// Returns the deduplicated, canonical pubkeys (excluding self).
|
||||
fn normalize_mention_pubkeys(
|
||||
raw: &[String],
|
||||
self_pubkey_hex: &str,
|
||||
) -> Result<Vec<String>, (StatusCode, axum::Json<serde_json::Value>)> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
seen.insert(self_pubkey_hex.to_ascii_lowercase());
|
||||
let mut result = Vec::new();
|
||||
for entry in raw {
|
||||
let canonical = entry.to_ascii_lowercase();
|
||||
if canonical.len() != 64 || !canonical.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("invalid mention_pubkeys entry: {entry}"),
|
||||
));
|
||||
}
|
||||
if seen.insert(canonical.clone()) {
|
||||
result.push(canonical);
|
||||
}
|
||||
}
|
||||
if result.len() > MAX_MENTION_PUBKEYS {
|
||||
return Err(api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!(
|
||||
"mention_pubkeys exceeds maximum of {MAX_MENTION_PUBKEYS} after deduplication (got {})",
|
||||
result.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Build Nostr tags for a kind:40008 diff message.
|
||||
///
|
||||
/// Required fields: `diff_repo_url` (must be http/https) and `diff_commit_sha`.
|
||||
@@ -530,8 +572,9 @@ pub struct SendMessageBody {
|
||||
pub content: String,
|
||||
/// Hex-encoded event ID of the parent message (for replies).
|
||||
pub parent_event_id: Option<String>,
|
||||
/// Hex-encoded pubkeys to mention in the message (added as `p` tags).
|
||||
/// Distinct from the author-attribution `p` tag which is always added.
|
||||
/// Hex-encoded pubkeys of users mentioned in this message.
|
||||
/// Each pubkey gets a `p` tag so mention-filtered subscriptions deliver the event.
|
||||
/// Normalized server-side: lowercased, deduplicated, self-mention skipped, capped at 50.
|
||||
#[serde(default)]
|
||||
pub mention_pubkeys: Vec<String>,
|
||||
/// When `true`, a reply is also surfaced in the channel feed (broadcast).
|
||||
@@ -707,19 +750,13 @@ pub async fn send_message(
|
||||
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
|
||||
];
|
||||
|
||||
// Mention p tags — distinct from the author-attribution p tag above.
|
||||
for mention_hex in &body.mention_pubkeys {
|
||||
// Validate hex format (32-byte pubkey = 64 hex chars)
|
||||
if mention_hex.len() == 64
|
||||
&& mention_hex
|
||||
.chars()
|
||||
.all(|c| matches!(c, '0'..='9' | 'a'..='f'))
|
||||
{
|
||||
tags.push(
|
||||
Tag::parse(&["p", mention_hex])
|
||||
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
|
||||
);
|
||||
}
|
||||
// Mention p-tags — each mentioned pubkey gets a `p` tag so that
|
||||
// mention-filtered subscriptions (e.g. ACP agent harness) receive the event.
|
||||
for mention in normalize_mention_pubkeys(&body.mention_pubkeys, &user_pubkey_hex)? {
|
||||
tags.push(
|
||||
Tag::parse(&["p", &mention])
|
||||
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
|
||||
);
|
||||
}
|
||||
|
||||
// Thread reply tags (NIP-10 style).
|
||||
@@ -1217,6 +1254,120 @@ pub async fn get_thread(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── normalize_mention_pubkeys tests ─────────────────────────────────────
|
||||
|
||||
const SELF_PK: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const OTHER_PK: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
const THIRD_PK: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
|
||||
|
||||
#[test]
|
||||
fn normalize_empty_array() {
|
||||
let result = normalize_mention_pubkeys(&[], SELF_PK).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_single_valid_mention() {
|
||||
let input = vec![OTHER_PK.to_string()];
|
||||
let result = normalize_mention_pubkeys(&input, SELF_PK).unwrap();
|
||||
assert_eq!(result, vec![OTHER_PK]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_skips_self_mention() {
|
||||
let input = vec![SELF_PK.to_string()];
|
||||
let result = normalize_mention_pubkeys(&input, SELF_PK).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_skips_self_mention_case_insensitive() {
|
||||
let upper_self = SELF_PK.to_uppercase();
|
||||
let input = vec![upper_self];
|
||||
let result = normalize_mention_pubkeys(&input, SELF_PK).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_deduplicates() {
|
||||
let input = vec![OTHER_PK.to_string(), OTHER_PK.to_string()];
|
||||
let result = normalize_mention_pubkeys(&input, SELF_PK).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0], OTHER_PK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_deduplicates_mixed_case() {
|
||||
let input = vec![OTHER_PK.to_string(), OTHER_PK.to_uppercase()];
|
||||
let result = normalize_mention_pubkeys(&input, SELF_PK).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0], OTHER_PK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_canonicalizes_to_lowercase() {
|
||||
let input = vec![OTHER_PK.to_uppercase()];
|
||||
let result = normalize_mention_pubkeys(&input, SELF_PK).unwrap();
|
||||
assert_eq!(result, vec![OTHER_PK]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_preserves_order() {
|
||||
let input = vec![THIRD_PK.to_string(), OTHER_PK.to_string()];
|
||||
let result = normalize_mention_pubkeys(&input, SELF_PK).unwrap();
|
||||
assert_eq!(result, vec![THIRD_PK, OTHER_PK]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_rejects_invalid_hex() {
|
||||
let input =
|
||||
vec!["zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz".to_string()];
|
||||
let err = normalize_mention_pubkeys(&input, SELF_PK);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_rejects_wrong_length() {
|
||||
let input = vec!["aabb".to_string()];
|
||||
let err = normalize_mention_pubkeys(&input, SELF_PK);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_rejects_exceeding_cap() {
|
||||
let input: Vec<String> = (0..MAX_MENTION_PUBKEYS + 1)
|
||||
.map(|i| format!("{:064x}", i))
|
||||
.collect();
|
||||
let err = normalize_mention_pubkeys(&input, SELF_PK);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_accepts_exactly_at_cap() {
|
||||
// Generate MAX_MENTION_PUBKEYS unique pubkeys (none matching SELF_PK).
|
||||
let input: Vec<String> = (1..=MAX_MENTION_PUBKEYS)
|
||||
.map(|i| format!("{:064x}", i))
|
||||
.collect();
|
||||
let result = normalize_mention_pubkeys(&input, SELF_PK).unwrap();
|
||||
assert_eq!(result.len(), MAX_MENTION_PUBKEYS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_over_cap_raw_but_under_after_dedupe() {
|
||||
// 51 raw entries: 50 unique pubkeys + 1 duplicate + self-mention.
|
||||
// After dedupe and self-skip the result should be exactly 50.
|
||||
let mut input: Vec<String> = (1..=MAX_MENTION_PUBKEYS)
|
||||
.map(|i| format!("{:064x}", i))
|
||||
.collect();
|
||||
input.push(SELF_PK.to_string()); // self-mention → skipped
|
||||
input.push(format!("{:064x}", 1)); // duplicate → collapsed
|
||||
assert!(input.len() > MAX_MENTION_PUBKEYS);
|
||||
let result = normalize_mention_pubkeys(&input, SELF_PK).unwrap();
|
||||
assert_eq!(result.len(), MAX_MENTION_PUBKEYS);
|
||||
}
|
||||
|
||||
// ── local media URL tests ───────────────────────────────────────────────
|
||||
|
||||
const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
||||
const BASE: &str = "https://relay.example.com/media";
|
||||
|
||||
|
||||
@@ -2039,3 +2039,325 @@ async fn test_batch_add_mixed_policies() {
|
||||
"error should mention policy:nobody; got: {error_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Thread reply mention p-tag tests ──────────────────────────────────────────
|
||||
|
||||
/// Thread replies created via REST with `mention_pubkeys` must include the
|
||||
/// extra `p` tags so that `#p`-filtered subscribers (e.g. ACP agent harness)
|
||||
/// receive the event. This is the regression test for the bug where
|
||||
/// @mentioning an agent in a thread reply did not trigger the agent.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_rest_reply_with_mention_pubkeys_includes_p_tags() {
|
||||
let client = http_client();
|
||||
let poster_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let poster_hex = poster_keys.public_key().to_hex();
|
||||
let agent_hex = agent_keys.public_key().to_hex();
|
||||
let ws_url = relay_ws_url();
|
||||
|
||||
// Create an open channel.
|
||||
let channels_url = format!("{}/api/channels", relay_http_url());
|
||||
let channel_name = format!("e2e-reply-mention-{}", uuid::Uuid::new_v4().simple());
|
||||
let create_resp = authed_post_json(
|
||||
&client,
|
||||
&channels_url,
|
||||
&poster_hex,
|
||||
serde_json::json!({
|
||||
"name": channel_name,
|
||||
"channel_type": "stream",
|
||||
"visibility": "open"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(create_resp.status(), 201, "failed to create test channel");
|
||||
let created: serde_json::Value = create_resp.json().await.expect("JSON");
|
||||
let channel_id = created["id"].as_str().expect("channel id").to_string();
|
||||
|
||||
// Subscribe as the "agent" with a #p filter matching agent_hex — this is
|
||||
// exactly how the ACP harness subscribes.
|
||||
let mut agent_sub = SproutTestClient::connect(&ws_url, &agent_keys)
|
||||
.await
|
||||
.expect("agent WS connect");
|
||||
let sid = format!("agent-mention-{}", uuid::Uuid::new_v4().simple());
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(9))
|
||||
.custom_tag(
|
||||
SingleLetterTag::lowercase(Alphabet::H),
|
||||
[channel_id.as_str()],
|
||||
)
|
||||
.custom_tag(
|
||||
SingleLetterTag::lowercase(Alphabet::P),
|
||||
[agent_hex.as_str()],
|
||||
);
|
||||
agent_sub
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
agent_sub
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("EOSE");
|
||||
|
||||
// Post a top-level message (the future thread root).
|
||||
let msg_url = format!("{}/api/channels/{}/messages", relay_http_url(), channel_id);
|
||||
let root_resp = authed_post_json(
|
||||
&client,
|
||||
&msg_url,
|
||||
&poster_hex,
|
||||
serde_json::json!({ "content": "thread root" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(root_resp.status(), 200);
|
||||
let root: serde_json::Value = root_resp.json().await.expect("JSON");
|
||||
let root_event_id = root["event_id"].as_str().expect("event_id").to_string();
|
||||
|
||||
// Post a reply that @mentions the agent via mention_pubkeys.
|
||||
let reply_resp = authed_post_json(
|
||||
&client,
|
||||
&msg_url,
|
||||
&poster_hex,
|
||||
serde_json::json!({
|
||||
"content": "hey @agent check this out",
|
||||
"parent_event_id": root_event_id,
|
||||
"mention_pubkeys": [agent_hex],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
reply_resp.status(),
|
||||
200,
|
||||
"reply with mention_pubkeys failed"
|
||||
);
|
||||
|
||||
// The agent subscriber should receive the reply because it has a p-tag
|
||||
// matching agent_hex.
|
||||
let msg = agent_sub
|
||||
.recv_event(Duration::from_secs(5))
|
||||
.await
|
||||
.expect("agent subscriber did not receive the reply — p-tag likely missing");
|
||||
|
||||
match msg {
|
||||
RelayMessage::Event { event, .. } => {
|
||||
assert_eq!(event.content, "hey @agent check this out");
|
||||
|
||||
let tags: Vec<Vec<String>> = event
|
||||
.tags
|
||||
.iter()
|
||||
.map(|tag| tag.as_slice().iter().map(|s| s.to_string()).collect())
|
||||
.collect();
|
||||
|
||||
// Must have the agent's p-tag (the mention).
|
||||
assert!(
|
||||
tags.iter()
|
||||
.any(|t| t.len() >= 2 && t[0] == "p" && t[1] == agent_hex),
|
||||
"reply is missing the agent mention p-tag: {tags:?}"
|
||||
);
|
||||
// Must still have the sender's p-tag (attribution).
|
||||
assert!(
|
||||
tags.iter()
|
||||
.any(|t| t.len() >= 2 && t[0] == "p" && t[1] == poster_hex),
|
||||
"reply is missing the sender attribution p-tag: {tags:?}"
|
||||
);
|
||||
// Must have the reply e-tag.
|
||||
assert!(
|
||||
tags.iter().any(|t| t.len() >= 4
|
||||
&& t[0] == "e"
|
||||
&& t[1] == root_event_id
|
||||
&& t[3] == "reply"),
|
||||
"reply is missing the NIP-10 reply e-tag: {tags:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected EVENT, got {other:?}"),
|
||||
}
|
||||
|
||||
agent_sub.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// mention_pubkeys validation: duplicates are deduplicated, self is skipped,
|
||||
/// mixed-case is canonicalized, and invalid entries are rejected.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_rest_mention_pubkeys_validation() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let pubkey_hex = keys.public_key().to_hex();
|
||||
|
||||
// Create a channel.
|
||||
let channels_url = format!("{}/api/channels", relay_http_url());
|
||||
let channel_name = format!("e2e-mention-val-{}", uuid::Uuid::new_v4().simple());
|
||||
let create_resp = authed_post_json(
|
||||
&client,
|
||||
&channels_url,
|
||||
&pubkey_hex,
|
||||
serde_json::json!({
|
||||
"name": channel_name,
|
||||
"channel_type": "stream",
|
||||
"visibility": "open"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(create_resp.status(), 201);
|
||||
let created: serde_json::Value = create_resp.json().await.expect("JSON");
|
||||
let channel_id = created["id"].as_str().expect("channel id").to_string();
|
||||
let msg_url = format!("{}/api/channels/{}/messages", relay_http_url(), channel_id);
|
||||
|
||||
// Invalid hex should be rejected.
|
||||
let resp = authed_post_json(
|
||||
&client,
|
||||
&msg_url,
|
||||
&pubkey_hex,
|
||||
serde_json::json!({
|
||||
"content": "bad mention",
|
||||
"mention_pubkeys": ["not-valid-hex"],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), 400, "invalid hex should be rejected");
|
||||
|
||||
// Too many mentions should be rejected.
|
||||
let too_many: Vec<String> = (0..51).map(|i| format!("{:064x}", i)).collect();
|
||||
let resp = authed_post_json(
|
||||
&client,
|
||||
&msg_url,
|
||||
&pubkey_hex,
|
||||
serde_json::json!({
|
||||
"content": "too many mentions",
|
||||
"mention_pubkeys": too_many,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
400,
|
||||
"exceeding 50 mentions should be rejected"
|
||||
);
|
||||
|
||||
// Empty array should succeed.
|
||||
let resp = authed_post_json(
|
||||
&client,
|
||||
&msg_url,
|
||||
&pubkey_hex,
|
||||
serde_json::json!({
|
||||
"content": "no mentions",
|
||||
"mention_pubkeys": [],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), 200, "empty mention_pubkeys should succeed");
|
||||
}
|
||||
|
||||
/// Thread reply with duplicate, uppercase, and self mention_pubkeys must
|
||||
/// produce exactly the expected normalized p-tags: sender first, then each
|
||||
/// unique mentioned pubkey lowercased, with self and duplicates removed.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_rest_mention_pubkeys_normalization_in_emitted_event() {
|
||||
let client = http_client();
|
||||
let poster_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let poster_hex = poster_keys.public_key().to_hex();
|
||||
let agent_hex = agent_keys.public_key().to_hex();
|
||||
let ws_url = relay_ws_url();
|
||||
|
||||
// Create an open channel.
|
||||
let channels_url = format!("{}/api/channels", relay_http_url());
|
||||
let channel_name = format!("e2e-norm-{}", uuid::Uuid::new_v4().simple());
|
||||
let create_resp = authed_post_json(
|
||||
&client,
|
||||
&channels_url,
|
||||
&poster_hex,
|
||||
serde_json::json!({
|
||||
"name": channel_name,
|
||||
"channel_type": "stream",
|
||||
"visibility": "open"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(create_resp.status(), 201);
|
||||
let created: serde_json::Value = create_resp.json().await.expect("JSON");
|
||||
let channel_id = created["id"].as_str().expect("channel id").to_string();
|
||||
|
||||
// Subscribe to the channel (no #p filter — we want to see all events).
|
||||
let mut sub = SproutTestClient::connect(&ws_url, &poster_keys)
|
||||
.await
|
||||
.expect("WS connect");
|
||||
let sid = format!("norm-{}", uuid::Uuid::new_v4().simple());
|
||||
let filter = Filter::new().kind(Kind::Custom(9)).custom_tag(
|
||||
SingleLetterTag::lowercase(Alphabet::H),
|
||||
[channel_id.as_str()],
|
||||
);
|
||||
sub.subscribe(&sid, vec![filter]).await.expect("subscribe");
|
||||
sub.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("EOSE");
|
||||
|
||||
// Post a root message.
|
||||
let msg_url = format!("{}/api/channels/{}/messages", relay_http_url(), channel_id);
|
||||
let root_resp = authed_post_json(
|
||||
&client,
|
||||
&msg_url,
|
||||
&poster_hex,
|
||||
serde_json::json!({ "content": "root" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(root_resp.status(), 200);
|
||||
let root: serde_json::Value = root_resp.json().await.expect("JSON");
|
||||
let root_id = root["event_id"].as_str().expect("event_id").to_string();
|
||||
|
||||
// Consume the root event so it doesn't interfere.
|
||||
sub.recv_event(Duration::from_secs(5))
|
||||
.await
|
||||
.expect("root event");
|
||||
|
||||
// Post a reply with: duplicate agent, uppercase agent, and self-mention.
|
||||
let reply_resp = authed_post_json(
|
||||
&client,
|
||||
&msg_url,
|
||||
&poster_hex,
|
||||
serde_json::json!({
|
||||
"content": "normalization test",
|
||||
"parent_event_id": root_id,
|
||||
"mention_pubkeys": [
|
||||
agent_hex,
|
||||
agent_hex.to_uppercase(),
|
||||
agent_hex,
|
||||
poster_hex,
|
||||
],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(reply_resp.status(), 200);
|
||||
|
||||
let msg = sub
|
||||
.recv_event(Duration::from_secs(5))
|
||||
.await
|
||||
.expect("reply event");
|
||||
|
||||
match msg {
|
||||
RelayMessage::Event { event, .. } => {
|
||||
let p_tags: Vec<String> = event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|t| t.kind().to_string() == "p")
|
||||
.filter_map(|t| t.content().map(|s| s.to_string()))
|
||||
.collect();
|
||||
|
||||
// Expect exactly 2 p-tags: sender (first) + agent (deduplicated, lowercased).
|
||||
// Self-mention (poster_hex) must be suppressed.
|
||||
assert_eq!(
|
||||
p_tags.len(),
|
||||
2,
|
||||
"expected exactly 2 p-tags (sender + agent), got {p_tags:?}"
|
||||
);
|
||||
assert_eq!(p_tags[0], poster_hex, "first p-tag must be sender");
|
||||
assert_eq!(
|
||||
p_tags[1], agent_hex,
|
||||
"second p-tag must be the agent (lowercased, deduplicated)"
|
||||
);
|
||||
}
|
||||
other => panic!("expected EVENT, got {other:?}"),
|
||||
}
|
||||
|
||||
sub.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
@@ -50,13 +50,15 @@ pub async fn send_channel_message(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SendChannelMessageResponse, String> {
|
||||
let path = format!("/api/channels/{channel_id}/messages");
|
||||
let mentions = mention_pubkeys.unwrap_or_default();
|
||||
let mention_refs: Vec<&str> = mentions.iter().map(|s| s.as_str()).collect();
|
||||
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?.json(
|
||||
&SendChannelMessageBody {
|
||||
content: content.trim(),
|
||||
parent_event_id: parent_event_id.as_deref(),
|
||||
broadcast_to_channel: false,
|
||||
media_tags,
|
||||
mention_pubkeys: mention_pubkeys.unwrap_or_default(),
|
||||
mention_pubkeys: mention_refs,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ pub struct SendChannelMessageBody<'a> {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub media_tags: Option<Vec<Vec<String>>>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub mention_pubkeys: Vec<String>,
|
||||
pub mention_pubkeys: Vec<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { updateChannelLastMessageAt } from "@/features/channels/hooks";
|
||||
import {
|
||||
buildReplyTags,
|
||||
normalizeMentionPubkeys,
|
||||
resolveReplyRootId,
|
||||
} from "@/features/messages/lib/threading";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
@@ -73,11 +74,16 @@ function createOptimisticMessage(
|
||||
identity.pubkey,
|
||||
parentEventId,
|
||||
resolveReplyRootId(parentEventId, currentMessages),
|
||||
mentionPubkeys,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
tags.push(["h", channelId]);
|
||||
for (const pubkey of mentionPubkeys) {
|
||||
tags.push(["p", identity.pubkey]);
|
||||
for (const pubkey of normalizeMentionPubkeys(
|
||||
mentionPubkeys,
|
||||
identity.pubkey,
|
||||
)) {
|
||||
tags.push(["p", pubkey]);
|
||||
}
|
||||
}
|
||||
@@ -266,10 +272,11 @@ export function useSendMessageMutation(
|
||||
identity.pubkey,
|
||||
parentEventId,
|
||||
resolveReplyRootId(parentEventId, cachedMessages),
|
||||
mentionPubkeys,
|
||||
)
|
||||
: [];
|
||||
const baseTags = parentEventId
|
||||
? replyTags // buildReplyTags includes h + author p
|
||||
? replyTags // buildReplyTags includes h + author p + mention ps
|
||||
: [
|
||||
["h", channel.id],
|
||||
["p", identity.pubkey],
|
||||
@@ -282,7 +289,13 @@ export function useSendMessageMutation(
|
||||
kind: KIND_STREAM_MESSAGE,
|
||||
tags: [
|
||||
...baseTags,
|
||||
...(mentionPubkeys ?? []).map((pk) => ["p", pk]),
|
||||
// For non-replies, add mention p-tags here (replies get them via buildReplyTags)
|
||||
...(!parentEventId
|
||||
? normalizeMentionPubkeys(
|
||||
mentionPubkeys ?? [],
|
||||
identity.pubkey,
|
||||
).map((pk) => ["p", pk])
|
||||
: []),
|
||||
...(mediaTags ?? []),
|
||||
],
|
||||
content: content.trim(),
|
||||
|
||||
@@ -42,17 +42,48 @@ export function getThreadReference(tags: string[][]): ThreadReference {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort client-side normalization of mention pubkeys: lowercase, deduplicate, skip self.
|
||||
* The relay performs authoritative validation (hex format, 64-char length, cap of 50)
|
||||
* on top of the same normalization — this helper keeps optimistic UI tags consistent.
|
||||
*/
|
||||
export function normalizeMentionPubkeys(
|
||||
mentionPubkeys: string[],
|
||||
selfPubkey: string,
|
||||
): string[] {
|
||||
const selfLower = selfPubkey.toLowerCase();
|
||||
const seen = new Set<string>([selfLower]);
|
||||
const result: string[] = [];
|
||||
for (const pk of mentionPubkeys) {
|
||||
const lower = pk.toLowerCase();
|
||||
if (seen.has(lower)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(lower);
|
||||
result.push(lower);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildReplyTags(
|
||||
channelId: string,
|
||||
authorPubkey: string,
|
||||
parentEventId: string,
|
||||
rootEventId: string,
|
||||
mentionPubkeys: string[] = [],
|
||||
) {
|
||||
const tags: string[][] = [
|
||||
["p", authorPubkey],
|
||||
["h", channelId],
|
||||
];
|
||||
|
||||
// Add p-tags for mentioned users so mention-filtered subscriptions
|
||||
// (e.g. ACP agent harness) receive the reply event.
|
||||
// Best-effort normalization — relay performs authoritative validation.
|
||||
for (const pubkey of normalizeMentionPubkeys(mentionPubkeys, authorPubkey)) {
|
||||
tags.push(["p", pubkey]);
|
||||
}
|
||||
|
||||
if (parentEventId === rootEventId) {
|
||||
tags.push(["e", rootEventId, "", "reply"]);
|
||||
return tags;
|
||||
|
||||
@@ -2271,6 +2271,7 @@ async function handleSendChannelMessage(
|
||||
channelId: string;
|
||||
content: string;
|
||||
parentEventId?: string | null;
|
||||
mentionPubkeys?: string[];
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawSendChannelMessageResponse> {
|
||||
@@ -2329,19 +2330,32 @@ async function handleSendChannelMessage(
|
||||
pubkey: getMockMemberPubkey(config),
|
||||
created_at: createdAt,
|
||||
kind: 9,
|
||||
tags:
|
||||
rootEventId === args.parentEventId
|
||||
? [
|
||||
["p", getMockMemberPubkey(config)],
|
||||
["h", args.channelId],
|
||||
["e", rootEventId, "", "reply"],
|
||||
]
|
||||
: [
|
||||
["p", getMockMemberPubkey(config)],
|
||||
["h", args.channelId],
|
||||
["e", rootEventId, "", "root"],
|
||||
["e", args.parentEventId, "", "reply"],
|
||||
],
|
||||
tags: (() => {
|
||||
const authorPubkey = getMockMemberPubkey(config);
|
||||
// Match production tag ordering: author p, h, mention ps, then e-tags.
|
||||
const tags: string[][] = [
|
||||
["p", authorPubkey],
|
||||
["h", args.channelId],
|
||||
];
|
||||
// Best-effort client-side normalization (relay is authoritative).
|
||||
const selfLower = authorPubkey.toLowerCase();
|
||||
const seen = new Set<string>([selfLower]);
|
||||
for (const pk of args.mentionPubkeys ?? []) {
|
||||
const lower = pk.toLowerCase();
|
||||
if (!seen.has(lower)) {
|
||||
seen.add(lower);
|
||||
tags.push(["p", lower]);
|
||||
}
|
||||
}
|
||||
// Thread e-tags come after mention p-tags.
|
||||
if (rootEventId === args.parentEventId) {
|
||||
tags.push(["e", rootEventId, "", "reply"]);
|
||||
} else {
|
||||
tags.push(["e", rootEventId, "", "root"]);
|
||||
tags.push(["e", args.parentEventId, "", "reply"]);
|
||||
}
|
||||
return tags;
|
||||
})(),
|
||||
content: args.content.trim(),
|
||||
sig: "mocksig".repeat(20).slice(0, 128),
|
||||
};
|
||||
@@ -2367,6 +2381,7 @@ async function handleSendChannelMessage(
|
||||
content: args.content,
|
||||
parent_event_id: args.parentEventId,
|
||||
broadcast_to_channel: false,
|
||||
mention_pubkeys: args.mentionPubkeys ?? [],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user