Skip to main content

vector_core/community/
realtime.rs

1//! Realtime Community (Concord) subscription, routing, dispatch, and control-follow.
2//!
3//! The per-event cryptographic engine ([`inbound::process_incoming`]) already lives in core;
4//! this module is the realtime *plumbing* around it — the relay subscription, the pseudonym→channel
5//! route maps, the dispatch of typed [`inbound::IncomingEvent`]s to an [`InboundEventHandler`], and
6//! the control/rekey realtime-follow. It is consumed by [`crate::VectorCore::listen`] so headless
7//! clients (SDK, CLI, bots) get realtime Community delivery through the same handler as DMs.
8
9use std::collections::{HashMap, HashSet};
10use std::sync::{LazyLock, Mutex as StdMutex};
11use std::sync::Arc;
12use std::time::Duration;
13
14use nostr_sdk::prelude::*;
15use tokio::sync::Mutex;
16
17use crate::community::{derive, inbound, roster, service, Channel, CommunityId, Epoch};
18use crate::community::transport::LiveTransport;
19use crate::event_handler::InboundEventHandler;
20use crate::state::SessionGuard;
21use crate::stored_event::event_kind;
22
23/// Realtime channel-follow retry budget: a re-founding publishes the channel rekey under the NEW
24/// root right after the base rekey, so the base-3303-triggered follow can race its propagation.
25const CHANNEL_FOLLOW_MAX_ATTEMPTS: usize = 5;
26const CHANNEL_FOLLOW_BACKOFF_MS: u64 = 700;
27
28/// Current Community subscription id — single subscription scoped to the epoch pseudonyms of
29/// every channel we hold; refreshed on join/leave/rekey.
30static COMMUNITY_SUB_ID: LazyLock<Mutex<Option<SubscriptionId>>> = LazyLock::new(|| Mutex::new(None));
31
32/// `z` pseudonym (hex) → the Channel it belongs to. Lets the notification loop open an arriving
33/// event against the right channel key (and read its `banned` set for the inbound drop-filter).
34static COMMUNITY_ROUTES: LazyLock<Mutex<HashMap<String, Channel>>> =
35    LazyLock::new(|| Mutex::new(HashMap::new()));
36
37/// Control-plane `z` pseudonym (hex) → community id (hex). Routes a CONTROL edition (3308) or a
38/// base/channel REKEY coordinate (3303) to a realtime control refresh of that community.
39static CONTROL_ROUTES: LazyLock<Mutex<HashMap<String, String>>> =
40    LazyLock::new(|| Mutex::new(HashMap::new()));
41
42/// Per-community in-flight guard for [`refresh_control`] — one concurrent fold per community.
43static REFRESH_CONTROL_INFLIGHT: LazyLock<StdMutex<HashSet<String>>> =
44    LazyLock::new(|| StdMutex::new(HashSet::new()));
45
46/// The subscription id of the live Community subscription, if any. Lets a notification loop test
47/// whether an arriving event belongs to the Community sub.
48pub async fn subscription_id() -> Option<SubscriptionId> {
49    COMMUNITY_SUB_ID.lock().await.clone()
50}
51
52/// Clear all realtime route/subscription state. Call from `swap_session` so a swapped-in account
53/// can't read the prior account's channel keys / banned sets.
54pub async fn clear() {
55    *COMMUNITY_SUB_ID.lock().await = None;
56    COMMUNITY_ROUTES.lock().await.clear();
57    CONTROL_ROUTES.lock().await.clear();
58    REFRESH_CONTROL_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner()).clear();
59}
60
61/// Rebuild ONLY the in-memory route maps from the persisted communities, WITHOUT touching the live
62/// relay subscription. Refreshes each cached `Channel` (incl. its `banned` set) so a received
63/// ban/unban takes live effect without a full resubscribe. Returns the pseudonyms + relays a caller
64/// also resubscribing needs.
65pub async fn rebuild_routes() -> (Vec<String>, HashSet<String>) {
66    let mut routes: HashMap<String, Channel> = HashMap::new();
67    let mut control_routes: HashMap<String, String> = HashMap::new();
68    let mut pseudonyms: Vec<String> = Vec::new();
69    let mut relays: HashSet<String> = HashSet::new();
70
71    if let Ok(ids) = crate::db::community::list_community_ids() {
72        for id in ids {
73            if let Ok(Some(community)) = crate::db::community::load_community(&id) {
74                for r in &community.relays {
75                    relays.insert(r.clone());
76                }
77                for ch in &community.channels {
78                    // Subscribe to EVERY held epoch pseudonym (not just the head) so a straggler posting
79                    // under a retained older epoch still arrives in realtime; the inbound router opens each
80                    // against the channel's full keyset.
81                    for (epoch, key) in ch.read_epoch_keys() {
82                        let pseudonym = derive::channel_pseudonym(&key, &ch.id, epoch).to_hex();
83                        pseudonyms.push(pseudonym.clone());
84                        routes.insert(pseudonym, ch.clone());
85                    }
86                    // The NEXT channel-rekey coordinate (3303) under the CURRENT server root — follow a
87                    // channel rotation event-driven the instant it lands.
88                    let next_chan = derive::rekey_pseudonym(
89                        &community.server_root_key, &ch.id, Epoch(ch.epoch.0 + 1),
90                    ).to_hex();
91                    pseudonyms.push(next_chan.clone());
92                    control_routes.insert(next_chan, community.id.to_hex());
93                }
94                // Control-plane pseudonym at the current server-root epoch (banlist/roles/metadata/invites).
95                let ctrl = roster::control_pseudonym(
96                    &community.server_root_key, &community.id, community.server_root_epoch,
97                );
98                pseudonyms.push(ctrl.clone());
99                control_routes.insert(ctrl, community.id.to_hex());
100                // The NEXT base re-founding coordinate (3303) — follow a privatize / private-ban in realtime.
101                let next_base = derive::base_rekey_pseudonym(
102                    &community.server_root_key, &community.id,
103                    Epoch(community.server_root_epoch.0 + 1),
104                ).to_hex();
105                pseudonyms.push(next_base.clone());
106                control_routes.insert(next_base, community.id.to_hex());
107            }
108        }
109    }
110
111    *COMMUNITY_ROUTES.lock().await = routes;
112    *CONTROL_ROUTES.lock().await = control_routes;
113    (pseudonyms, relays)
114}
115
116/// (Re)build the Community subscription: rebuild the route maps, then open a single subscription
117/// scoped to every held channel/control pseudonym, targeted at the communities' relays.
118pub async fn refresh_subscription(client: &Client) {
119    let (pseudonyms, relays) = rebuild_routes().await;
120
121    // Hold COMMUNITY_SUB_ID across the unsubscribe+subscribe ON PURPOSE: it serializes concurrent
122    // refreshers (Monitor, health-probe reconnect, refresh_control, accept_invite) so they can't
123    // race into a duplicate subscription. Narrowing this lock would reintroduce that double-sub race.
124    let mut sub_guard = COMMUNITY_SUB_ID.lock().await;
125    if let Some(old_id) = sub_guard.take() {
126        client.unsubscribe(&old_id).await;
127    }
128
129    if pseudonyms.is_empty() {
130        return;
131    }
132
133    // Community events live on the community's relays, which may differ from the user's DM relays.
134    // Add them GOSSIP|PING (warm, but excluded from pool-wide DM/profile ops) and subscribe by TARGET.
135    for r in &relays {
136        let _ = client.pool().add_relay(r.as_str(), crate::community_relay_options()).await;
137    }
138    client.connect().await;
139
140    let filter = Filter::new()
141        .kinds([
142            Kind::Custom(event_kind::COMMUNITY_MESSAGE),
143            Kind::Custom(event_kind::COMMUNITY_REACTION),
144            Kind::Custom(event_kind::COMMUNITY_EDIT),
145            Kind::Custom(event_kind::COMMUNITY_DELETE),
146            Kind::Custom(event_kind::COMMUNITY_PRESENCE),
147            Kind::Custom(event_kind::COMMUNITY_KICK),
148            Kind::Custom(event_kind::COMMUNITY_WEBXDC),
149            Kind::Custom(event_kind::COMMUNITY_CONTROL),
150            Kind::Custom(event_kind::COMMUNITY_REKEY),
151        ])
152        .custom_tags(SingleLetterTag::lowercase(Alphabet::Z), pseudonyms)
153        .limit(0);
154
155    match client.subscribe_to(relays.iter().cloned(), filter, None).await {
156        Ok(output) => *sub_guard = Some(output.val),
157        Err(e) => crate::log_warn!("[community] subscribe failed: {:?}", e),
158    }
159}
160
161/// Route an arriving Community event: a CONTROL/REKEY edition triggers a realtime control refresh;
162/// any other (message/reaction/edit/delete/presence/typing/webxdc/kick) is opened against its
163/// channel via [`inbound::process_incoming`], persisted, and dispatched to `handler`. `session`
164/// straddles the relay I/O so a mid-flight account swap can't write into the swapped-in account.
165pub async fn dispatch_event(
166    session: &SessionGuard,
167    event: Event,
168    handler: Arc<dyn InboundEventHandler>,
169) {
170    let Some(my_pk) = crate::my_public_key() else { return; };
171    let Some(pseudonym) = event.tags.iter().find_map(|t| {
172        let s = t.as_slice();
173        (s.len() >= 2 && s[0] == "z").then(|| s[1].clone())
174    }) else { return; };
175
176    // A CONTROL edition (3308) or base/channel REKEY (3303) → follow the control plane in realtime.
177    let kind = event.kind.as_u16();
178    if kind == event_kind::COMMUNITY_CONTROL || kind == event_kind::COMMUNITY_REKEY {
179        let community_id = CONTROL_ROUTES.lock().await.get(&pseudonym).cloned();
180        if let Some(community_id) = community_id {
181            if session.is_valid() {
182                // Spawn off the loop — the refresh runs several relay fetches (seconds) and must not
183                // head-of-line-block other event consumption. It self-captures a guard + re-checks.
184                tokio::spawn(refresh_control(community_id, handler.clone()));
185            }
186        }
187        return;
188    }
189
190    let Some(channel) = COMMUNITY_ROUTES.lock().await.get(&pseudonym).cloned() else { return; };
191    if !session.is_valid() {
192        return;
193    }
194
195    let outcome = {
196        let mut state = crate::state::STATE.lock().await;
197        inbound::process_incoming(&mut state, &event, &channel, &my_pk)
198    };
199    let chat_id = channel.id.to_hex();
200    match outcome {
201        Some(inbound::IncomingEvent::NewMessage(msg)) => {
202            let _ = crate::db::events::save_message(&chat_id, &msg).await;
203            handler.on_community_message(&chat_id, &msg, true);
204        }
205        Some(inbound::IncomingEvent::Updated { target_id, message, edit_event }) => {
206            // Edits are event-sourced (folded on reload); reactions re-save the message row.
207            if let Some(ev) = edit_event {
208                let mut ev = (*ev).clone();
209                if let Ok(cid) = crate::db::id_cache::get_chat_id_by_identifier(&chat_id) {
210                    ev.chat_id = cid;
211                }
212                let _ = crate::db::events::save_event(&ev).await;
213            } else {
214                let _ = crate::db::events::save_message(&chat_id, &message).await;
215            }
216            handler.on_community_update(&chat_id, &target_id, &message);
217        }
218        Some(inbound::IncomingEvent::Removed { target_id }) => {
219            let _ = crate::db::events::delete_event(&target_id).await;
220            handler.on_community_removed(&chat_id, &target_id);
221        }
222        Some(inbound::IncomingEvent::Presence { npub, joined, event_id, created_at, invited_by, invited_label }) => {
223            handler.on_community_presence(
224                &chat_id, &npub, joined, &event_id, created_at,
225                invited_by.as_deref(), invited_label.as_deref(),
226            );
227        }
228        Some(inbound::IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, event_id, created_at }) => {
229            handler.on_community_webxdc(
230                &chat_id, &npub, &topic_id, node_addr.as_deref(), &event_id, created_at,
231            );
232        }
233        Some(inbound::IncomingEvent::Typing { npub, until }) => {
234            handler.on_community_typing(&chat_id, &npub, until);
235        }
236        Some(inbound::IncomingEvent::Kicked { community_id })
237        | Some(inbound::IncomingEvent::SelfLeft { community_id }) => {
238            // The handler owns teardown (the GUI prunes chats/relays + republishes the list; a
239            // headless consumer can call `teardown_local`). Core only routes + notifies here.
240            handler.on_community_self_removed(&community_id);
241        }
242        None => {}
243    }
244}
245
246/// Realtime control-plane follow: walk a re-founding, fold the control plane (banlist/roles/
247/// metadata/invites), follow channel rekeys, then resubscribe at the new pseudonyms if any epoch
248/// advanced (else just refresh the route maps so the new banlist takes live effect). Mirrors the
249/// Tauri `refresh_community_control` orchestration over the already-core `catch_up_*` primitives.
250pub async fn refresh_control(community_id: String, handler: Arc<dyn InboundEventHandler>) {
251    // Claim the in-flight slot or bail (a concurrent refresh is already folding this community).
252    {
253        let mut inflight = REFRESH_CONTROL_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner());
254        if !inflight.insert(community_id.clone()) {
255            return;
256        }
257    }
258    struct RefreshClaim(String);
259    impl Drop for RefreshClaim {
260        fn drop(&mut self) {
261            REFRESH_CONTROL_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner()).remove(&self.0);
262        }
263    }
264    let _claim = RefreshClaim(community_id.clone());
265
266    let session = SessionGuard::capture();
267    let Some(id_bytes) = hex_to_id32(&community_id) else { return; };
268    let Some(community) = crate::db::community::load_community(&CommunityId(id_bytes)).ok().flatten() else { return; };
269    let bt = LiveTransport::with_timeout(Duration::from_secs(20));
270    let pre_server_epoch = community.server_root_epoch.0;
271    let pre_channel_epochs: Vec<(String, u64)> =
272        community.channels.iter().map(|c| (c.id.to_hex(), c.epoch.0)).collect();
273
274    // FOLLOW FIRST: a privatize / private-ban re-founds the base under a NEW epoch + re-anchors the
275    // control plane there, so walk the rotation BEFORE folding control. An AUTHORIZED rotation that
276    // excluded us is a removal → tear down locally.
277    if let Ok(c) = service::catch_up_server_root(&bt, &community).await {
278        if !session.is_valid() { return; }
279        if c.removed { handler.on_community_self_removed(&community_id); return; }
280    }
281    if !session.is_valid() { return; }
282    let community = crate::db::community::load_community(&CommunityId(id_bytes)).ok().flatten().unwrap_or(community);
283    let _ = service::fetch_and_apply_control(&bt, &community).await;
284    if !session.is_valid() { return; }
285    // Banned by the just-folded banlist → torn down, nothing more to do.
286    if let Some(c) = crate::db::community::load_community(&CommunityId(id_bytes)).ok().flatten() {
287        if service::am_i_banned(&c) {
288            handler.on_community_self_removed(&community_id);
289            return;
290        }
291    }
292    if !session.is_valid() { return; }
293
294    // Walk each channel's rekey chain. A re-founding rotates base AND every channel once; the channel
295    // rekey publishes right after the base rekey, so a single fetch can race propagation — retry with a
296    // short backoff until every channel reaches the expected epoch (next sync is the backstop).
297    let base_delta = crate::db::community::load_community(&CommunityId(id_bytes)).ok().flatten()
298        .map(|c| c.server_root_epoch.0).unwrap_or(pre_server_epoch).saturating_sub(pre_server_epoch);
299    for attempt in 0..CHANNEL_FOLLOW_MAX_ATTEMPTS {
300        if !session.is_valid() { return; }
301        let Some(cur) = crate::db::community::load_community(&CommunityId(id_bytes)).ok().flatten() else { break; };
302        for ch in &cur.channels {
303            let _ = service::catch_up_channel_rekeys(&bt, &cur, &ch.id).await;
304        }
305        let caught = base_delta == 0 || crate::db::community::load_community(&CommunityId(id_bytes)).ok().flatten()
306            .map(|c| c.channels.iter().all(|ch| {
307                let pre = pre_channel_epochs.iter().find(|(id, _)| id == &ch.id.to_hex()).map(|(_, e)| *e).unwrap_or(ch.epoch.0);
308                ch.epoch.0 >= pre.saturating_add(base_delta)
309            }))
310            .unwrap_or(true);
311        if caught { break; }
312        if attempt + 1 < CHANNEL_FOLLOW_MAX_ATTEMPTS {
313            tokio::time::sleep(Duration::from_millis(CHANNEL_FOLLOW_BACKOFF_MS)).await;
314        }
315    }
316    if !session.is_valid() { return; }
317    let community = crate::db::community::load_community(&CommunityId(id_bytes)).ok().flatten().unwrap_or(community);
318    let _ = service::retry_pending_read_cut(&bt, &community).await;
319    if !session.is_valid() { return; }
320    let community = crate::db::community::load_community(&CommunityId(id_bytes)).ok().flatten().unwrap_or(community);
321
322    // If an epoch advanced, rebuild the FULL subscription so realtime delivery resumes at the new
323    // pseudonyms; else just refresh the route maps so the inbound drop-filter sees the new banlist.
324    let advanced = community.server_root_epoch.0 != pre_server_epoch
325        || community.channels.iter().any(|c| {
326            pre_channel_epochs.iter().find(|(id, _)| id == &c.id.to_hex()).map(|(_, e)| *e != c.epoch.0).unwrap_or(true)
327        });
328    if advanced && session.is_valid() {
329        if let Some(client) = crate::state::nostr_client() {
330            refresh_subscription(&client).await;
331        }
332    } else {
333        let _ = rebuild_routes().await;
334    }
335    if !session.is_valid() { return; }
336    crate::community::list::refresh_membership_current(&community);
337    handler.on_community_refreshed(&community_id);
338}
339
340/// Tear down a community locally on a received removal (kick/leave/ban), RETAINING the held epoch
341/// keys (so a self-scrub republish/erase still works), then refresh the subscription so we stop
342/// listening on its pseudonyms. Headless consumers can call this from `on_community_self_removed`;
343/// the GUI does a richer teardown (prune chats/relays + republish the list) in its handler instead.
344pub async fn teardown_local(community_id: &str) {
345    let _ = crate::db::community::delete_community_retain_keys(community_id);
346    if let Some(client) = crate::state::nostr_client() {
347        refresh_subscription(&client).await;
348    }
349}
350
351/// hex (64 chars) → 32-byte id.
352fn hex_to_id32(hex: &str) -> Option<[u8; 32]> {
353    (hex.len() == 64).then(|| crate::simd::hex::hex_to_bytes_32(hex))
354}