fix: include mention p-tags in thread replies for ACP agent delivery (#68)

This commit is contained in:
tlongwell-block
2026-03-15 17:21:49 -04:00
committed by GitHub
parent 2b08ad80d0
commit 076050c4f6
8 changed files with 575 additions and 34 deletions

View File

@@ -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)

View File

@@ -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";

View File

@@ -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");
}

View File

@@ -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,
},
);

View File

@@ -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)]

View File

@@ -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(),

View File

@@ -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;

View File

@@ -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 ?? [],
}),
},
);