diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 88dd5f5180..80abd3654d 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -557,9 +557,173 @@ async fn dispatch_persistent_event_inner( }); } + // Offline-mention safety net (#1743): if a channel message *explicitly* + // @mentions an agent (bot member) that has no presence key, the fan-out + // above delivered nothing to it — it has no live subscription. Rather + // than let the mention silently vanish, emit a system notice into the + // channel so the sender and any human observer can see the target is + // offline. Only `mention` tags count (not structural reply-author `p` + // tags from buildReplyTags). Spawned so it never adds latency to + // dispatch; best-effort (never blocks or fails the event). + // + // Presence is a reachability warning only — not a delivery receipt that + // a particular harness subscription admitted the event (see also #2386). + if kind_u32 == buzz_core::kind::KIND_STREAM_MESSAGE { + if let Some(channel_id) = stored_event.channel_id { + let mentioned = explicit_mention_pubkeys_from_tags(&stored_event.event.tags); + if !mentioned.is_empty() { + let tenant = tenant.clone(); + let state = Arc::clone(state); + tokio::spawn(async move { + notify_offline_agent_mentions(&tenant, &state, channel_id, mentioned).await; + }); + } + } + } + matches.len() } +/// Collect explicit `@mention` targets from event tags. +/// +/// Producers emit `["mention", pubkey]` in parallel with `p` for intentional +/// authored @mentions (SDK/CLI `build_message`, Desktop ordinary composer / +/// `buildReplyTags` mention loop, Mobile `SendMessage`). The non-member +/// "send without inviting" path also uses bare `mention` tags. +/// Structural reply-author `p` alone must **not** count (Brad #2296 / #1862). +/// +/// - Dedupes first-seen hex (lowercased) +/// - Skips malformed tags (missing/empty pubkey) +/// - Ignores bare `p` tags entirely +pub(crate) fn explicit_mention_pubkeys_from_tags(tags: &nostr::Tags) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for t in tags.iter() { + if t.kind().to_string() != "mention" { + continue; + } + let Some(raw) = t.content() else { + continue; + }; + let hex = raw.trim().to_ascii_lowercase(); + if hex.is_empty() { + continue; + } + if seen.insert(hex.clone()) { + out.push(hex); + } + } + out +} + +/// Pure selection: mentioned bot hexes with no presence key (offline). +/// Presence (any status value) means "not offline" for this warning — we do +/// **not** treat offline presence statuses specially and we never claim delivery. +pub(crate) fn select_offline_mentioned_bots( + mentioned_hex: &[String], + bot_hexes: &std::collections::HashSet, + present: &std::collections::HashMap, +) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for hex in mentioned_hex { + if !bot_hexes.contains(hex) { + continue; + } + if present.contains_key(hex) { + continue; + } + if seen.insert(hex.clone()) { + out.push(hex.clone()); + } + } + out +} + +/// Emit a system notice for any *explicitly* @mentioned agent (bot member) +/// that is offline when the mention is dispatched (#1743). +/// +/// Best-effort and side-effect-only: presence/DB lookups or emit failures are +/// logged and skipped — they never fail the original message ingest (caller +/// already identical-spawned after dispatch). +/// +/// Only agents that are (a) bot members of this community and (b) have no +/// presence key are reported. Human `p` / non-bot primary tags never emit an +/// agent notice. Online agents (any presence key) are never flagged. +async fn notify_offline_agent_mentions( + tenant: &TenantContext, + state: &Arc, + channel_id: uuid::Uuid, + mentioned_hex: Vec, +) { + use nostr::PublicKey; + + let bots = match state.db.get_bot_members(tenant.community()).await { + Ok(bots) => bots, + Err(e) => { + warn!(channel = %channel_id, error = %e, "offline-mention: bot lookup failed"); + return; + } + }; + if bots.is_empty() { + return; + } + let mut bot_names: std::collections::HashMap = std::collections::HashMap::new(); + for b in &bots { + let hex = hex::encode(&b.pubkey); + let name = b + .display_name + .clone() + .unwrap_or_else(|| format!("agent {}", &hex[..hex.len().min(8)])); + bot_names.insert(hex, name); + } + + let mentioned_bots: Vec<(String, PublicKey)> = mentioned_hex + .iter() + .filter(|hex| bot_names.contains_key(*hex)) + .filter_map(|hex| PublicKey::from_hex(hex).ok().map(|pk| (hex.clone(), pk))) + .collect(); + if mentioned_bots.is_empty() { + return; + } + + let pubkeys: Vec = mentioned_bots.iter().map(|(_, pk)| *pk).collect(); + let present = match state.pubsub.get_presence_bulk(tenant, &pubkeys).await { + Ok(map) => map, + Err(e) => { + // Presence failure must not affect the original message (already accepted). + warn!(channel = %channel_id, error = %e, "offline-mention: presence lookup failed"); + return; + } + }; + + let bot_hex_set: std::collections::HashSet = + mentioned_bots.iter().map(|(hex, _)| hex.clone()).collect(); + let candidate_hex: Vec = mentioned_bots.iter().map(|(hex, _)| hex.clone()).collect(); + let offline = select_offline_mentioned_bots(&candidate_hex, &bot_hex_set, &present); + for hex in &offline { + let name = bot_names + .get(hex) + .cloned() + .unwrap_or_else(|| "agent".to_string()); + let content = serde_json::json!({ + "type": "agent_mention_undelivered", + "agent_pubkey": hex, + "agent_name": name, + "message": format!("{name} is offline and may not see this mention."), + }); + if let Err(e) = + crate::handlers::side_effects::emit_system_message(tenant, state, channel_id, content) + .await + { + // Emit failure must not fail the original message (already accepted). + warn!(channel = %channel_id, error = %e, "offline-mention: system notice emit failed"); + } else { + metrics::counter!("buzz_agent_mention_undelivered_total").increment(1); + } + } +} + async fn enqueue_event_created_audit( tenant: &TenantContext, state: &Arc, @@ -2480,5 +2644,193 @@ mod tests { must not receive a community-B event. Got: {out:?}" ); } + + #[test] + fn offline_mentioned_bots_selects_only_offline_bot_members() { + use std::collections::{HashMap, HashSet}; + + let alice = "aa".repeat(32); + let bob = "bb".repeat(32); + let human = "cc".repeat(32); + + // alice + bob are bot members; human is not a bot. + let bots: HashSet = [alice.clone(), bob.clone()].into_iter().collect(); + + // alice is online (present), bob is offline (absent). + let mut present: HashMap = HashMap::new(); + present.insert(alice.clone(), "online".to_string()); + + let mentioned = vec![alice.clone(), bob.clone(), human.clone()]; + let offline = + crate::handlers::event::select_offline_mentioned_bots(&mentioned, &bots, &present); + + // Only bob: alice is online, human is not a bot member. + assert_eq!(offline, vec![bob]); + } + + #[test] + fn offline_mentioned_bots_empty_when_all_online_or_non_bots() { + use std::collections::{HashMap, HashSet}; + let alice = "aa".repeat(32); + let bots: HashSet = [alice.clone()].into_iter().collect(); + let mut present: HashMap = HashMap::new(); + present.insert(alice.clone(), "away".to_string()); + // alice present (any status) => not flagged; unknown pubkey not a bot. + let mentioned = vec![alice, "dd".repeat(32)]; + assert!(crate::handlers::event::select_offline_mentioned_bots( + &mentioned, &bots, &present + ) + .is_empty()); + } + + #[test] + fn offline_mentioned_bots_dedupes_duplicate_mentions() { + use std::collections::{HashMap, HashSet}; + let bob = "bb".repeat(32); + let bots: HashSet = [bob.clone()].into_iter().collect(); + let present: HashMap = HashMap::new(); + let mentioned = vec![bob.clone(), bob.clone(), bob.clone()]; + let offline = + crate::handlers::event::select_offline_mentioned_bots(&mentioned, &bots, &present); + assert_eq!(offline, vec![bob]); + } + + #[test] + fn explicit_mention_tags_ignore_structural_p_only() { + // Brad matrix: buildReplyTags-style structural author `p` alone → no notice candidates. + let tags = + nostr::Tags::from_list(vec![nostr::Tag::parse(["p", &"aa".repeat(32)]).unwrap()]); + assert!(crate::handlers::event::explicit_mention_pubkeys_from_tags(&tags).is_empty()); + } + + #[test] + fn explicit_mention_tags_collect_mention_kind_only() { + let bot = "bb".repeat(32); + let human = "cc".repeat(32); + let tags = nostr::Tags::from_list(vec![ + // structural reply author p — must not count + nostr::Tag::parse(["p", &"aa".repeat(32)]).unwrap(), + // human p recipient — not a mention tag + nostr::Tag::parse(["p", &human]).unwrap(), + // explicit composer mentions + nostr::Tag::custom(nostr::TagKind::Custom("mention".into()), [&bot]), + nostr::Tag::custom(nostr::TagKind::Custom("mention".into()), [&human]), + ]); + let found = crate::handlers::event::explicit_mention_pubkeys_from_tags(&tags); + assert_eq!(found, vec![bot, human]); + } + + #[test] + fn explicit_mention_tags_dedupe_and_skip_malformed() { + let bot = "bb".repeat(32); + let tags = nostr::Tags::from_list(vec![ + nostr::Tag::custom(nostr::TagKind::Custom("mention".into()), [&bot]), + // duplicate + nostr::Tag::custom( + nostr::TagKind::Custom("mention".into()), + [&bot.to_ascii_uppercase()], + ), + // empty content — custom with no values may omit content + nostr::Tag::custom( + nostr::TagKind::Custom("mention".into()), + Vec::::new(), + ), + // wrong kind still ignored + nostr::Tag::parse(["p", &bot]).unwrap(), + ]); + let found = crate::handlers::event::explicit_mention_pubkeys_from_tags(&tags); + assert_eq!(found, vec![bot]); + } + + #[test] + fn explicit_mention_from_sdk_style_tags_not_structural_p() { + // Contract: intentional mentions carry parallel p+mention; structural + // reply author is p-only and must not extract. + let author = "aa".repeat(32); + let offline_bot = "bb".repeat(32); + let structural_only = nostr::Tags::from_list(vec![ + nostr::Tag::parse(["p", &author]).unwrap(), + nostr::Tag::parse(["h", "00000000-0000-0000-0000-000000000001"]).unwrap(), + ]); + assert!( + crate::handlers::event::explicit_mention_pubkeys_from_tags(&structural_only) + .is_empty() + ); + + let intentional = nostr::Tags::from_list(vec![ + nostr::Tag::parse(["p", &author]).unwrap(), + nostr::Tag::parse(["h", "00000000-0000-0000-0000-000000000001"]).unwrap(), + nostr::Tag::parse(["p", &offline_bot]).unwrap(), + nostr::Tag::custom( + nostr::TagKind::Custom("mention".into()), + [&offline_bot], + ), + ]); + assert_eq!( + crate::handlers::event::explicit_mention_pubkeys_from_tags(&intentional), + vec![offline_bot.clone()] + ); + + use std::collections::{HashMap, HashSet}; + let bots: HashSet = [offline_bot.clone()].into_iter().collect(); + let present: HashMap = HashMap::new(); + let mentioned = + crate::handlers::event::explicit_mention_pubkeys_from_tags(&intentional); + assert_eq!( + crate::handlers::event::select_offline_mentioned_bots( + &mentioned, &bots, &present + ), + vec![offline_bot] + ); + } + + #[test] + fn offline_matrix_explicit_offline_bot_only() { + use std::collections::{HashMap, HashSet}; + let online_bot = "aa".repeat(32); + let offline_bot = "bb".repeat(32); + let human = "cc".repeat(32); + let bots: HashSet = [online_bot.clone(), offline_bot.clone()] + .into_iter() + .collect(); + let mut present = HashMap::new(); + present.insert(online_bot.clone(), "online".to_string()); + + // human p only → not selected (not bot) + assert!(crate::handlers::event::select_offline_mentioned_bots( + &[human.clone()], + &bots, + &present + ) + .is_empty()); + + // online agent → no notice + assert!(crate::handlers::event::select_offline_mentioned_bots( + &[online_bot.clone()], + &bots, + &present + ) + .is_empty()); + + // offline agent → exactly one + assert_eq!( + crate::handlers::event::select_offline_mentioned_bots( + &[offline_bot.clone()], + &bots, + &present + ), + vec![offline_bot.clone()] + ); + + // mix: only offline bot + assert_eq!( + crate::handlers::event::select_offline_mentioned_bots( + &[online_bot, offline_bot.clone(), human], + &bots, + &present + ), + vec![offline_bot] + ); + } } } diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 97c31c2561..3c9d92d0d3 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -296,6 +296,11 @@ impl ActionSink for RelayActionSink { Tag::parse(["p", &mentioned]) .map_err(|e| ActionSinkError::EventBuild(format!("mention p tag: {e}")))?, ); + tags.push( + Tag::parse(["mention", &mentioned]).map_err(|e| { + ActionSinkError::EventBuild(format!("mention intent tag: {e}")) + })?, + ); } let kind = Kind::from(KIND_STREAM_MESSAGE as u16); diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index f9e54de9c5..339d697c1e 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -184,7 +184,12 @@ fn thread_tags(thread_ref: &ThreadRef, tags: &mut Vec) -> Result<(), SdkErr Ok(()) } -/// Deduplicate and cap mentions, emitting p-tags. +/// Deduplicate and cap intentional mentions. +/// +/// Emits both `["p", hex]` (delivery / subscription fan-out) and +/// `["mention", hex]` (explicit intent marker). Relay offline-agent notices +/// (#1743 / #2296) gate on `mention` only so structural reply-author `p` +/// tags never false-trigger. fn mention_tags(mentions: &[&str], tags: &mut Vec) -> Result<(), SdkError> { if mentions.len() > crate::mentions::MENTION_CAP { return Err(SdkError::TooManyMentions); @@ -194,6 +199,7 @@ fn mention_tags(mentions: &[&str], tags: &mut Vec) -> Result<(), SdkError> let lower = hex.to_ascii_lowercase(); if seen.insert(lower.clone()) { tags.push(tag(&["p", &lower])?); + tags.push(tag(&["mention", &lower])?); } } Ok(()) @@ -213,7 +219,8 @@ fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), Sdk /// - `channel_id`: target channel UUID /// - `content`: message text (max 64 KiB) /// - `thread_ref`: optional NIP-10 reply context -/// - `mentions`: pubkey hex strings to p-tag (deduped, max 50) +/// - `mentions`: pubkey hex strings for intentional @mentions (deduped, max 50); +/// emits both `p` and `mention` tags /// - `broadcast`: if true, adds `["broadcast", "1"]` tag /// - `media_tags`: raw imeta tag vectors pub fn build_message( @@ -1983,6 +1990,18 @@ mod tests { let ev = sign(build_message(cid, "hi", None, &[hex, hex], false, &[]).unwrap()); let p_tags = tag_values(&ev, "p"); assert_eq!(p_tags.len(), 1); + let mention_tags = tag_values(&ev, "mention"); + assert_eq!(mention_tags, vec![hex.to_string()]); + } + + #[test] + fn message_mentions_emit_parallel_p_and_mention() { + let cid = uuid(); + let a = "aa".repeat(32); + let b = "bb".repeat(32); + let ev = sign(build_message(cid, "hi @a @b", None, &[&a, &b], false, &[]).unwrap()); + assert_eq!(tag_values(&ev, "p"), vec![a.clone(), b.clone()]); + assert_eq!(tag_values(&ev, "mention"), vec![a, b]); } #[test] diff --git a/crates/buzz-sdk/src/mentions.rs b/crates/buzz-sdk/src/mentions.rs index e59580c7ae..5b4a10ffb7 100644 --- a/crates/buzz-sdk/src/mentions.rs +++ b/crates/buzz-sdk/src/mentions.rs @@ -15,7 +15,7 @@ //! │ //! body text ──► strip_code_regions ──► extract_nostr_uris ─┤ //! ▼ -//! explicit mentions ──► normalize ──► merge_mentions ──► p-tags +//! explicit mentions ──► normalize ──► merge_mentions ──► p + mention tags //! ``` //! //! When the set of known member names is available upfront, @@ -31,7 +31,7 @@ use std::collections::HashSet; use nostr::{FromBech32, PublicKey}; -/// Maximum number of mention p-tags allowed on a single message. +/// Maximum number of intentional mentions allowed on a single message. /// /// Matches the cap enforced by Buzz message builders and the legacy MCP /// inline implementation. diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 777d56d02e..91da0120e0 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -70,7 +70,9 @@ fn mention_tags(mentions: &[&str]) -> Result, String> { check_pubkey(hex)?; let lower = hex.to_ascii_lowercase(); if seen.insert(lower.clone()) { + // Parallel p (fan-out) + mention (explicit intent) for #2296 contract. tags.push(tag(vec!["p", &lower])?); + tags.push(tag(vec!["mention", &lower])?); } } Ok(tags) @@ -404,9 +406,9 @@ pub fn build_forum_comment( /// /// `mentions` carries the pubkeys of mentions that are *newly added* by this /// edit (the caller diffs the edited body against the original). Only those get -/// a `p` tag so the newly-mentioned party is notified/woken, while a typo-fix -/// edit that leaves the mention set unchanged emits no `p` tags and never -/// re-wakes anyone. This mirrors the send path's `mention_tags` (dedup + +/// parallel `p` + `mention` tags so the newly-mentioned party is notified/woken, +/// while a typo-fix edit that leaves the mention set unchanged emits neither +/// and never re-wakes anyone. Mirrors the send path's `mention_tags` (dedup + /// lowercase); the receiver overlays these onto the original event's audience. pub fn build_message_edit( channel_id: Uuid, @@ -931,11 +933,11 @@ mod tests { assert_eq!(event.pubkey.to_hex(), TARGET_HEX); } - // ── build_message_edit `p`-tag emission (lane 8ace8eed) ────────────── + // ── build_message_edit p+mention emission (lane 8ace8eed) ────────────── // // The composer diffs the edited body's mentions against the original and // hands `build_message_edit` only the *newly added* pubkeys. These tests - // pin the builder's contract given that contract: emit a `p` per added + // pins the builder's contract: emit parallel `p` + `mention` per added // mention (deduped, lowercased), and none when the added set is empty // (typo-fix edit) — so an unchanged mention set re-wakes nobody. @@ -962,20 +964,21 @@ mod tests { let tags = edit_tags(&[ALICE_HEX]); assert_eq!(tags[0][0], "h"); assert_eq!(tags[1][0], "e"); - // The `p` tag rides right after the `e` tag (insertion order). + // `p` + parallel `mention` ride right after the `e` tag (insertion order). assert_eq!(tags[2], vec!["p".to_string(), ALICE_HEX.to_string()]); + assert_eq!(tags[3], vec!["mention".to_string(), ALICE_HEX.to_string()]); } #[test] fn edit_with_no_added_mentions_emits_no_p_tag() { // Typo-fix edit: mention set unchanged, so the composer passes `&[]`. - // The edit event must carry no `p` tag and re-wake nobody. + // The edit event must carry no `p`/`mention` tags and re-wake nobody. let tags = edit_tags(&[]); assert!( - !tags - .iter() - .any(|t| t.first().map(String::as_str) == Some("p")), - "unchanged-mention edit must not emit any `p` tag, got {tags:?}" + !tags.iter().any(|t| { + matches!(t.first().map(String::as_str), Some("p" | "mention")) + }), + "unchanged-mention edit must not emit p/mention tags, got {tags:?}" ); } @@ -995,5 +998,18 @@ mod tests { ); assert_eq!(p_tags[0], &vec!["p".to_string(), ALICE_HEX.to_string()]); assert_eq!(p_tags[1], &vec!["p".to_string(), BOB_HEX.to_string()]); + let mention_tags: Vec<&Vec> = tags + .iter() + .filter(|t| t.first().map(String::as_str) == Some("mention")) + .collect(); + assert_eq!(mention_tags.len(), 2); + assert_eq!( + mention_tags[0], + &vec!["mention".to_string(), ALICE_HEX.to_string()] + ); + assert_eq!( + mention_tags[1], + &vec!["mention".to_string(), BOB_HEX.to_string()] + ); } } diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 062b0ee40b..cb0f30c8db 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -109,6 +109,7 @@ export function createOptimisticMessage( identity.pubkey, )) { tags.push(["p", pubkey]); + tags.push(["mention", pubkey]); } } @@ -512,8 +513,11 @@ export function useSendMessageMutation( ...baseTags, // For non-replies, add mention p-tags here (replies get them via buildReplyTags) ...(!parentEventId - ? normalizeMentionPubkeys(recipientPubkeys, identity.pubkey).map( - (pk) => ["p", pk], + ? normalizeMentionPubkeys(recipientPubkeys, identity.pubkey).flatMap( + (pk) => [ + ["p", pk], + ["mention", pk], + ], ) : []), ...imetaTags, diff --git a/desktop/src/features/messages/lib/threading.ts b/desktop/src/features/messages/lib/threading.ts index 95694f4989..298bbaae02 100644 --- a/desktop/src/features/messages/lib/threading.ts +++ b/desktop/src/features/messages/lib/threading.ts @@ -110,11 +110,13 @@ export function buildReplyTags( ["h", channelId], ]; - // Add p-tags for mentioned users so mention-filtered subscriptions - // (e.g. ACP agent harness) receive the reply event. + // Intentional @mentions: `p` for subscription fan-out + explicit `mention` + // intent marker (relay offline-agent notice gates on mention only; structural + // reply-author `p` above must never count as a mention). // Best-effort normalization — relay performs authoritative validation. for (const pubkey of normalizeMentionPubkeys(mentionPubkeys, authorPubkey)) { tags.push(["p", pubkey]); + tags.push(["mention", pubkey]); } if (parentEventId === rootEventId) { diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index b8c861af96..37a43f7a37 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -302,6 +302,7 @@ export class RelayClient { const tags: string[][] = [["h", channelId]]; for (const pubkey of mentionPubkeys) { tags.push(["p", pubkey]); + tags.push(["mention", pubkey]); } for (const tag of extraTags) { tags.push(tag); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index da398ca4ba..3568f97d65 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -3472,6 +3472,7 @@ function appendMentionTags( } seen.add(lower); tags.push(["p", lower]); + tags.push(["mention", lower]); } } diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index 1771b05f12..e1f5ccfae7 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -51,10 +51,16 @@ class SendMessage { if (seenMentions.add(pk.toLowerCase())) pk, ]; + // Intentional @mentions emit `p` (fan-out) + `mention` (explicit intent). + // Structural reply routing uses only `e` tags here; relay offline notices + // gate on `mention` so we never treat reply-author `p` as a mention. final tags = >[ ['h', channelId], if (parentEventId != null) ..._buildReplyTags(parentEventId, rootEventId), - for (final pk in normalizedMentions) ['p', pk], + for (final pk in normalizedMentions) ...[ + ['p', pk], + ['mention', pk], + ], ...mediaTags, ];