Skip to main content

vector_core/
lib.rs

1//! Vector Core — the single source of truth for all Vector clients, SDKs, and interfaces.
2//!
3//! This crate contains ALL of Vector's business logic, fully decoupled from Tauri.
4//! It can be used by:
5//! - **src-tauri**: The Tauri desktop/mobile app (thin command shell)
6//! - **vector-cli**: Command-line interface
7//! - **Vector SDK**: Bot and client libraries
8//! - Any future interface (web, embedded, etc.)
9//!
10//! # Architecture
11//!
12//! ```text
13//! ┌─────────────────────────────────────────────┐
14//! │              vector-core                     │
15//! │                                              │
16//! │  types ─ compact ─ state ─ db ─ crypto       │
17//! │  chat ─ profile ─ net ─ hex                  │
18//! │                                              │
19//! │  traits::EventEmitter (UI abstraction)       │
20//! │  VectorCore (high-level API)                 │
21//! └─────────────────────────────────────────────┘
22//!        ▲              ▲              ▲
23//!   src-tauri       vector-cli     Vector SDK
24//! (AppHandle)      (terminal)     (callbacks)
25//! ```
26
27// === Logging (must be first — #[macro_export] macros used by all modules) ===
28#[macro_use]
29mod macros;
30
31// === Foundation ===
32pub mod error;
33pub mod traits;
34
35// Nostr SDK trait imports needed for bech32 operations
36use nostr_sdk::prelude::ToBech32;
37
38// === Core Types ===
39pub mod types;
40pub mod profile;
41pub mod chat;
42pub mod compact;
43
44// === State ===
45pub mod state;
46
47// === Debug Stats ===
48#[cfg(debug_assertions)]
49pub mod stats;
50
51// === Crypto ===
52pub mod crypto;
53
54// === Signer (polymorphic: local vault vs. NIP-46 remote bunker) ===
55pub mod signer;
56
57// === Database ===
58pub mod db;
59
60// === Network ===
61pub mod net;
62pub mod negentropy;
63pub mod blossom;
64pub mod blossom_servers;
65pub mod blossom_capabilities;
66pub mod inbox_relays;
67pub mod emoji_packs;
68pub mod emoji_usage;
69pub mod badges;
70pub mod bot_interface;
71pub mod webxdc;
72#[cfg(feature = "tor")]
73pub mod tor;
74
75/// Build a `nostr_sdk::ClientOptions` with the embedded-Tor SOCKS proxy
76/// applied if (and only if) the `tor` feature is on AND `tor::TorService` is
77/// currently active. When Tor is off, returns the default options unchanged.
78///
79/// Note: nostr-sdk's `proxy()` lives on `Connection`, not `ClientOptions`
80/// directly — we build a `Connection` with the proxy mode and pass it via
81/// `ClientOptions::connection(...)`. The `connection()` method itself is
82/// `#[cfg(not(target_arch = "wasm32"))]`, but Vector targets are all native.
83///
84/// Callers should use this rather than `ClientOptions::new()` directly so the
85/// Tor toggle automatically covers their relay traffic.
86pub fn nostr_client_options() -> nostr_sdk::ClientOptions {
87    // NIP-42: authenticate to relays that challenge, using the account signer. Many
88    // Concord/Armada communities live on AUTH-gating relays (Ditto's default gates
89    // kind-1059), where an unauthenticated client silently reads back ZERO events —
90    // so a join's control-plane verify fails closed and every community fetch comes
91    // up empty. Auto-auth unlocks those reads; a relay that doesn't challenge is
92    // unaffected.
93    let opts = nostr_sdk::ClientOptions::new().automatic_authentication(true);
94    #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
95    {
96        match tor::transport_state() {
97            tor::TorTransportState::Active(addr) => {
98                return opts.connection(nostr_sdk::client::Connection::new().proxy(addr));
99            }
100            tor::TorTransportState::RequiredButInactive => {
101                // Tor failsafe: route to a blackhole so the relay socket can't
102                // accidentally come up direct while Tor is mid-bootstrap.
103                return opts.connection(
104                    nostr_sdk::client::Connection::new().proxy(tor::blackhole_proxy_addr()),
105                );
106            }
107            tor::TorTransportState::Disabled => {}
108        }
109    }
110    opts
111}
112
113/// Augment a `RelayOptions` with the Tor connection mode when active. Used
114/// by every site that adds a relay to the pool — default relays at boot,
115/// custom user relays, community relays, NIP-17 inbox relays — so they all
116/// come up through Tor when the toggle is on.
117///
118/// Without this, `RelayOptions::new()` (or the per-mode helper) defaults to
119/// `ConnectionMode::Direct`, and relays added at boot would stay direct even
120/// after Tor is bootstrapped — `switch_relay_transport()` covers existing
121/// relays on toggle, but not freshly-added ones.
122pub fn tor_aware_relay_options(opts: nostr_sdk::RelayOptions) -> nostr_sdk::RelayOptions {
123    #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
124    {
125        match tor::transport_state() {
126            tor::TorTransportState::Active(addr) => {
127                return opts.connection_mode(nostr_sdk::pool::ConnectionMode::proxy(addr));
128            }
129            tor::TorTransportState::RequiredButInactive => {
130                // Tor failsafe: pin to blackhole so this relay can never come
131                // up direct while Tor isn't running.
132                return opts.connection_mode(
133                    nostr_sdk::pool::ConnectionMode::proxy(tor::blackhole_proxy_addr()),
134                );
135            }
136            tor::TorTransportState::Disabled => {}
137        }
138    }
139    opts
140}
141
142/// Relay options for a Community / "external" relay: the GOSSIP flag (+ PING for a 24/7 keepalive
143/// connection). GOSSIP is read/write-capable when TARGETED — `can_read()` is `READ|GOSSIP|DISCOVERY`
144/// and `can_write()` is `WRITE|GOSSIP`, so per-relay checks pass for `fetch_events_from` /
145/// `send_event_to` / `subscribe_to`. But pool-wide ops select READ-only / WRITE-only relays, so the
146/// DM/giftwrap subscription (`subscribe(None)`) and the user's outbox (`send_event`) skip GOSSIP
147/// relays — the user's own traffic never touches relays they don't own. (A bare PING-only relay can
148/// NOT be used: `can_write()`/`can_read()` are false → the relay layer returns WriteDisabled /
149/// ReadDisabled.) An overlap relay that's ALSO a user relay keeps its existing READ+WRITE flags —
150/// `add_relay` is a no-op (`Ok(false)`) for a url already pooled, reusing the one existing connection.
151pub fn community_relay_options() -> nostr_sdk::RelayOptions {
152    use nostr_sdk::RelayServiceFlags;
153    tor_aware_relay_options(
154        nostr_sdk::RelayOptions::new().flags(RelayServiceFlags::GOSSIP | RelayServiceFlags::PING),
155    )
156}
157
158/// Relay options for a Discovery Relay (see `state::DISCOVERY_RELAYS`): the same
159/// GOSSIP|PING targeted-only isolation as Community relays — reachable via
160/// `fetch_events_from` / `send_event_to`, invisible to pool-wide DM/profile ops.
161/// An overlap with a user relay keeps the user's READ+WRITE flags (`add_relay`
162/// no-ops on an already-pooled url).
163pub fn discovery_relay_options() -> nostr_sdk::RelayOptions {
164    community_relay_options()
165}
166
167// === Event Storage ===
168pub mod stored_event;
169
170// === Rumor Processing ===
171pub mod rumor;
172
173// === Messaging ===
174pub mod sending;
175
176// === Per-DM Wallpapers ===
177pub mod wallpaper;
178
179// === Message Deletion (NIP-09 against retained gift-wraps) ===
180pub mod deletion;
181pub mod self_destruct;
182
183// === SIMD Operations ===
184pub mod simd;
185
186// === Community protocol (GROUP_PROTOCOL.md) ===
187pub mod community;
188
189// === Event Handler ===
190pub mod event_handler;
191
192// === Re-exports for convenience ===
193pub use types::{Message, Attachment, Reaction, EditEntry, ImageMetadata, SiteMetadata, LoginResult, AttachmentFile, mention, extract_mentions};
194pub use profile::{Profile, ProfileFlags, SlimProfile, Status};
195pub use chat::{Chat, ChatType, ChatMetadata, SerializableChat};
196pub use compact::{CompactMessage, CompactMessageVec, NpubInterner};
197pub use state::{
198    ChatState, NOSTR_CLIENT, MY_SECRET_KEY, MY_PUBLIC_KEY, STATE, ENCRYPTION_KEY,
199    nostr_client, my_public_key, has_active_session,
200    set_nostr_client, set_my_public_key,
201    take_nostr_client, clear_my_public_key,
202    set_pending_bunker_setup, pending_bunker_setup, clear_pending_bunker_setup,
203};
204pub use crypto::{GuardedKey, GuardedSigner};
205pub use signer::{
206    SignerKind, signer_kind, set_signer_kind, is_bunker,
207    BUNKER_SIGNER, bunker_signer, set_bunker_signer, take_bunker_signer,
208    build_bunker_signer, prewarm_bunker, drain_bunker_state,
209    parse_bunker_remote_pubkey, parse_bunker_relays,
210    BunkerConnectionState, bunker_state, set_bunker_state,
211    VectorAuthUrlHandler, attempt_bunker_login, WatchedBunkerSigner,
212    vector_metadata, build_nostrconnect_uri, build_nostrconnect_session,
213    VECTOR_APP_NAME, VECTOR_APP_URL, VECTOR_APP_ICON,
214};
215pub use error::{VectorError, Result};
216pub use traits::{EventEmitter, NoOpEmitter, set_event_emitter, emit_event};
217pub use db::{set_app_data_dir, get_app_data_dir};
218pub use sending::{SendCallback, NoOpSendCallback, SendConfig, SendResult};
219pub use deletion::{delete_own_dm, DeleteOutcome};
220pub use stored_event::{StoredEvent, StoredEventBuilder, SystemEventType};
221pub use rumor::{RumorEvent, RumorContext, ConversationType, RumorProcessingResult, process_rumor};
222pub use profile::{SyncPriority, ProfileSyncHandler, NoOpProfileSyncHandler};
223pub use event_handler::{InboundEventHandler, NoOpEventHandler, PreparedEvent, process_event};
224
225use std::path::PathBuf;
226use std::sync::Arc;
227
228// ============================================================================
229// VectorCore — High-level API
230// ============================================================================
231
232/// Configuration for initializing VectorCore.
233pub struct CoreConfig {
234    /// Path to the app data directory (e.g., ~/.local/share/io.vectorapp/data/)
235    pub data_dir: PathBuf,
236    /// Optional event emitter for UI integration
237    pub event_emitter: Option<Box<dyn EventEmitter>>,
238}
239
240/// The main entry point for Vector Core.
241///
242/// Provides a high-level API for all Vector operations. Internally uses
243/// global state (same pattern as the Tauri backend) for compatibility.
244///
245/// ```no_run
246/// use vector_core::{VectorCore, CoreConfig};
247/// use std::path::PathBuf;
248///
249/// # async fn example() -> vector_core::Result<()> {
250/// let core = VectorCore::init(CoreConfig {
251///     data_dir: PathBuf::from("/tmp/vector-data"),
252///     event_emitter: None,
253/// })?;
254///
255/// // Login with nsec
256/// let result = core.login("nsec1...", None).await?;
257/// println!("Logged in as {}", result.npub);
258/// # Ok(())
259/// # }
260/// ```
261#[derive(Clone, Copy)]
262pub struct VectorCore;
263
264impl VectorCore {
265    /// Initialize Vector Core with the given configuration.
266    pub fn init(config: CoreConfig) -> Result<Self> {
267        // Set data directory
268        db::set_app_data_dir(config.data_dir);
269
270        // Set event emitter (or no-op)
271        if let Some(emitter) = config.event_emitter {
272            traits::set_event_emitter(emitter);
273        }
274
275        // Install rustls ring provider
276        let _ = rustls::crypto::ring::default_provider().install_default();
277
278        Ok(VectorCore)
279    }
280
281    /// Get all available accounts.
282    pub fn accounts(&self) -> Result<Vec<String>> {
283        db::get_accounts().map_err(VectorError::from)
284    }
285
286    /// Login with an nsec key or mnemonic seed phrase.
287    pub async fn login(&self, key: &str, password: Option<&str>) -> Result<LoginResult> {
288        use nostr_sdk::prelude::*;
289
290        // Parse the key
291        let keys = if key.starts_with("nsec1") {
292            let secret = SecretKey::from_bech32(key)
293                .map_err(|e| VectorError::Nostr(format!("Invalid nsec: {}", e)))?;
294            Keys::new(secret)
295        } else {
296            // Treat as mnemonic (NIP-06: derive from BIP-39 seed)
297            Keys::from_mnemonic(key, None)
298                .map_err(|e| VectorError::Nostr(format!("Key derivation failed: {}", e)))?
299        };
300
301        let public_key = keys.public_key();
302        let npub = public_key.to_bech32()
303            .map_err(|e| VectorError::Nostr(format!("Failed to encode npub: {}", e)))?;
304
305        // Store in GuardedKey vault (pass other vaults to protect during decoy writes)
306        let secret_bytes = keys.secret_key().to_secret_bytes();
307        state::MY_SECRET_KEY.set(secret_bytes, &[&state::ENCRYPTION_KEY]);
308        state::set_my_public_key(public_key);
309
310        // Initialize database for this account
311        db::set_current_account(npub.clone())?;
312        db::init_database(&npub)?;
313
314        // Store nsec for encryption setup
315        {
316            let nsec = keys.secret_key().to_bech32()
317                .map_err(|e| VectorError::Nostr(format!("Failed to encode nsec: {}", e)))?;
318            *state::PENDING_NSEC.lock().unwrap() = Some(nsec.clone());
319
320            // NEVER clobber an existing encrypted key with the plaintext nsec. An account with encryption
321            // enabled keeps its key encrypted-at-rest (PIN-derived); overwriting it with the raw nsec — e.g.
322            // a no-password headless/diagnostic login (the concord CLI) — would leave the GUI deriving the
323            // right key from the correct PIN but trying to decrypt a value that's no longer ciphertext, i.e.
324            // "incorrect pin" with the real key effectively lost. MY_SECRET_KEY is already set in-memory above,
325            // so login works regardless; only persist the raw key when there's no encrypted key to protect.
326            let existing_encrypted = db::get_pkey().ok().flatten().is_some_and(|v| !v.starts_with("nsec1"));
327            if !(state::resolve_encryption_enabled_from_db() && existing_encrypted) {
328                db::set_pkey(&nsec)?;
329            }
330        }
331
332        // Use the canonical resolver so this high-level API agrees with
333        // crypto::is_encryption_enabled and the Android bg-sync probe.
334        let has_encryption = state::resolve_encryption_enabled_from_db();
335
336        if has_encryption {
337            if let Some(pwd) = password {
338                let key = crate::crypto::hash_pass(pwd).await;
339                state::ENCRYPTION_KEY.set(key, &[&state::MY_SECRET_KEY]);
340            }
341        }
342        // Seed the atomic unconditionally — `is_encryption_enabled_fast()`
343        // must agree with the DB regardless of branch.
344        state::init_encryption_enabled();
345
346        // Build Nostr client — tor-aware options so a headless consumer with
347        // the Tor pref ON proxies (or blackholes) instead of dialing direct.
348        let client = ClientBuilder::new()
349            .signer(keys)
350            .opts(nostr_client_options())
351            // Relay health monitor — powers the reconnect-driven catch-up in `listen()`.
352            .monitor(Monitor::new(1024))
353            .build();
354
355        // Add trusted relays
356        for relay in state::TRUSTED_RELAYS {
357            let opts = tor_aware_relay_options(nostr_sdk::RelayOptions::default());
358            client.pool().add_relay(*relay, opts).await.ok();
359        }
360
361        // Connect
362        client.connect().await;
363
364        let _ = { state::set_nostr_client(client); Ok::<(), ()>(()) };
365
366        Ok(LoginResult { npub, has_encryption })
367    }
368
369    /// Generate a fresh random account secret key (bech32 nsec). Lets a headless client spin up a
370    /// brand-new identity (`add_account` with no key) without depending on nostr-sdk directly.
371    pub fn generate_nsec(&self) -> Result<String> {
372        use nostr_sdk::prelude::*;
373        Keys::generate().secret_key().to_bech32()
374            .map_err(|e| VectorError::Nostr(format!("Failed to encode nsec: {}", e)))
375    }
376
377    /// Send a NIP-17 gift-wrapped text DM using the full pipeline. Retries a
378    /// transient publish miss (headless preset, 3 attempts) so an SDK/CLI bot rides
379    /// out a relay blip instead of silently dropping the message on the first miss;
380    /// `self_send: false` keeps it a plain send (no inbox self-copy).
381    pub async fn send_dm(&self, to_npub: &str, content: &str) -> Result<sending::SendResult> {
382        let config = SendConfig { self_send: false, ..SendConfig::headless() };
383        sending::send_dm(to_npub, content, None, &config, Arc::new(NoOpSendCallback)).await
384            .map_err(|e| VectorError::Other(e))
385    }
386
387    /// Send a DM as a threaded reply to `replied_to` (an existing message's event id).
388    pub async fn send_dm_reply(&self, to_npub: &str, replied_to: &str, content: &str) -> Result<sending::SendResult> {
389        let config = SendConfig { self_send: false, ..SendConfig::headless() };
390        sending::send_dm(to_npub, content, Some(replied_to), &config, Arc::new(NoOpSendCallback)).await
391            .map_err(|e| VectorError::Other(e))
392    }
393
394    /// Download a received attachment and decrypt it to plaintext bytes. Fetches the encrypted blob
395    /// from its Blossom URL (SSRF/Tor-aware client, size-capped) and AES-decrypts with the
396    /// attachment's embedded key + nonce.
397    pub async fn download_attachment(&self, attachment: &Attachment) -> Result<Vec<u8>> {
398        use futures_util::StreamExt;
399        const MAX_DOWNLOAD: usize = 256 * 1024 * 1024;
400        if attachment.url.is_empty() {
401            return Err(VectorError::Other("attachment has no URL".into()));
402        }
403        // SSRF guard: the URL is attacker-controlled (off an inbound message). build_http_client only
404        // validates redirect HOPS, not the initial request — so validate it here (matches the native
405        // download path). With Tor off this is the only egress guard.
406        crate::net::validate_url_not_private(&attachment.url)
407            .map_err(|e| VectorError::Other(e.to_string()))?;
408        let client = crate::net::build_http_client(std::time::Duration::from_secs(120)).map_err(VectorError::Other)?;
409        let resp = client.get(&attachment.url).send().await
410            .map_err(|e| VectorError::Other(format!("download: {e}")))?;
411        if !resp.status().is_success() {
412            return Err(VectorError::Other(format!("download failed: HTTP {}", resp.status())));
413        }
414        // Stream with a cap so a hostile/oversized blob can't OOM the process.
415        let mut encrypted: Vec<u8> = Vec::with_capacity(
416            resp.content_length().map(|l| (l as usize).min(MAX_DOWNLOAD)).unwrap_or(64 * 1024),
417        );
418        let mut stream = resp.bytes_stream();
419        while let Some(chunk) = stream.next().await {
420            let chunk = chunk.map_err(|e| VectorError::Other(format!("read body: {e}")))?;
421            if encrypted.len() + chunk.len() > MAX_DOWNLOAD {
422                return Err(VectorError::Other("attachment exceeds 256 MiB cap".into()));
423            }
424            encrypted.extend_from_slice(&chunk);
425        }
426        crate::crypto::decrypt_data(&encrypted, &attachment.key, &attachment.nonce).map_err(VectorError::Other)
427    }
428
429    /// Send a NIP-17 gift-wrapped file attachment DM.
430    pub async fn send_file(&self, to_npub: &str, file_path: &str) -> Result<sending::SendResult> {
431        let path = std::path::Path::new(file_path);
432        let bytes = std::fs::read(path)
433            .map_err(|e| VectorError::Io(e))?;
434        let filename = path.file_name()
435            .and_then(|n| n.to_str())
436            .unwrap_or("file");
437        let extension = path.extension()
438            .and_then(|e| e.to_str())
439            .unwrap_or("bin");
440
441        sending::send_file_dm(
442            to_npub,
443            std::sync::Arc::new(bytes),
444            filename,
445            extension,
446            None,
447            &SendConfig::default(),
448            Arc::new(NoOpSendCallback),
449        ).await.map_err(|e| VectorError::Other(e))
450    }
451
452    /// Send a NIP-25 reaction to a DM message. `emoji_url` carries the NIP-30
453    /// image URL when reacting with a custom-pack emoji (content stays
454    /// `:shortcode:`). Returns the reaction's rumor id. Local echo + persistence
455    /// are best-effort — the gift-wrap send is the source of truth.
456    pub async fn send_reaction(
457        &self,
458        to_npub: &str,
459        reference_id: &str,
460        emoji: &str,
461        emoji_url: Option<&str>,
462    ) -> Result<String> {
463        use nostr_sdk::prelude::*;
464
465        let client = state::nostr_client().ok_or(VectorError::Other("Not connected".into()))?;
466        let my_public_key = state::my_public_key().ok_or(VectorError::Other("Not logged in".into()))?;
467
468        let reference_event = EventId::from_hex(reference_id)
469            .map_err(|e| VectorError::Nostr(e.to_string()))?;
470        let receiver_pubkey = PublicKey::from_bech32(to_npub)
471            .map_err(|e| VectorError::Nostr(e.to_string()))?;
472
473        // NIP-30 custom-emoji tag — only when content is `:shortcode:` and a URL is present.
474        let custom_emoji_tag = emoji_url.and_then(|url| {
475            if !emoji.starts_with(':') || !emoji.ends_with(':') || emoji.len() < 3 || url.is_empty() {
476                return None;
477            }
478            let shortcode = &emoji[1..emoji.len() - 1];
479            if shortcode.is_empty() { return None; }
480            Some(Tag::custom(TagKind::custom("emoji"), [shortcode.to_string(), url.to_string()]))
481        });
482
483        let reaction_target = nostr_sdk::nips::nip25::ReactionTarget {
484            event_id: reference_event,
485            public_key: receiver_pubkey,
486            coordinate: None,
487            kind: Some(Kind::PrivateDirectMessage),
488            relay_hint: None,
489        };
490        let mut builder = EventBuilder::reaction(reaction_target, emoji);
491        if let Some(tag) = custom_emoji_tag {
492            builder = builder.tag(tag);
493        }
494        let rumor = builder.build(my_public_key);
495        let inner_rumor_id = rumor.id;
496        let rumor_id = inner_rumor_id.ok_or(VectorError::Other("Failed to get rumor ID".into()))?.to_hex();
497
498        // Retain the recipient wrap's ephemeral key + targeted relays so the
499        // reaction can later be revoked with a NIP-09 relay nuke (mirrors the
500        // DM message send path). Without retention the reaction is undeletable.
501        let outcome = inbox_relays::send_gift_wrap_retained(&client, &receiver_pubkey, rumor.clone(), [])
502            .await.map_err(VectorError::Other)?;
503        if !outcome.output.success.is_empty() {
504            if let Some(rid) = inner_rumor_id {
505                if let Err(e) = db::nip17_keys::store_wrap_key(
506                    &outcome.wrap_event_id, &rid, &receiver_pubkey,
507                    db::nip17_keys::WrapRole::Recipient,
508                    &outcome.wrap_secret, &outcome.targeted_relays,
509                ) {
510                    crate::log_warn!("[Reaction] failed to persist wrap key: {}", e);
511                }
512            }
513        }
514
515        // Self-wrap for multi-device recovery + retain its key too, so another
516        // device (or this one) can later revoke. Bail on account swap.
517        let self_wrap_client = client.clone();
518        let self_wrap_session = state::SessionGuard::capture();
519        tokio::spawn(async move {
520            if !self_wrap_session.is_valid() { return; }
521            if let Ok(self_outcome) = inbox_relays::send_gift_wrap_retained(
522                &self_wrap_client, &my_public_key, rumor, [],
523            ).await {
524                if !self_wrap_session.is_valid() { return; }
525                if !self_outcome.output.success.is_empty() {
526                    if let Some(rid) = inner_rumor_id {
527                        let _ = db::nip17_keys::store_wrap_key(
528                            &self_outcome.wrap_event_id, &rid, &my_public_key,
529                            db::nip17_keys::WrapRole::SelfSend,
530                            &self_outcome.wrap_secret, &self_outcome.targeted_relays,
531                        );
532                    }
533                }
534            }
535        });
536
537        // Best-effort optimistic local echo + persistence.
538        let reaction = Reaction {
539            id: rumor_id.clone(),
540            reference_id: reference_id.to_string(),
541            author_id: my_public_key.to_bech32().unwrap_or_else(|_| my_public_key.to_hex()),
542            emoji: emoji.to_string(),
543            emoji_url: emoji_url.map(|s| s.to_string()),
544        };
545        let msg_for_save = {
546            let mut st = state::STATE.lock().await;
547            match st.add_reaction_to_message(reference_id, reaction) {
548                Some((cid, true)) => st.find_message(reference_id).map(|(_, m)| (cid, m)),
549                _ => None,
550            }
551        };
552        if let Some((cid, msg)) = msg_for_save {
553            let _ = db::events::save_message(&cid, &msg).await;
554            traits::emit_event_json("message_update", serde_json::json!({
555                "old_id": reference_id, "message": &msg, "chat_id": &cid
556            }));
557        }
558
559        Ok(rumor_id)
560    }
561
562    /// Send an ephemeral typing indicator to a DM recipient. Fire-and-forget
563    /// with a 30-second NIP-40 expiry so relays purge it quickly.
564    pub async fn send_typing(&self, to_npub: &str) -> Result<()> {
565        use nostr_sdk::prelude::*;
566
567        let client = state::nostr_client().ok_or(VectorError::Other("Not connected".into()))?;
568        let my_public_key = state::my_public_key().ok_or(VectorError::Other("Not logged in".into()))?;
569        let pubkey = PublicKey::from_bech32(to_npub).map_err(|e| VectorError::Nostr(e.to_string()))?;
570
571        let expiry = Timestamp::from_secs(Timestamp::now().as_secs() + 30);
572        let rumor = EventBuilder::new(Kind::ApplicationSpecificData, "typing")
573            .tag(Tag::public_key(pubkey))
574            .tag(Tag::custom(TagKind::d(), vec!["vector"]))
575            .tag(Tag::expiration(expiry))
576            .build(my_public_key);
577
578        client.gift_wrap_to(
579            state::active_trusted_relays().await,
580            &pubkey,
581            rumor,
582            [Tag::expiration(expiry)],
583        ).await.map_err(|e| VectorError::Nostr(e.to_string()))?;
584        Ok(())
585    }
586
587    /// Edit a DM you previously sent (kind-16 edit) with an optimistic local
588    /// echo. Returns the edit event id. Persistence is best-effort and only
589    /// happens when the chat already exists locally.
590    pub async fn edit_dm(&self, to_npub: &str, message_id: &str, new_content: &str) -> Result<String> {
591        use nostr_sdk::prelude::*;
592
593        let client = state::nostr_client().ok_or(VectorError::Other("Not connected".into()))?;
594        let my_public_key = state::my_public_key().ok_or(VectorError::Other("Not logged in".into()))?;
595        let my_npub = my_public_key.to_bech32().map_err(|e| VectorError::Nostr(e.to_string()))?;
596        let receiver_pubkey = PublicKey::from_bech32(to_npub).map_err(|e| VectorError::Nostr(e.to_string()))?;
597        let reference_event = EventId::from_hex(message_id).map_err(|e| VectorError::Nostr(e.to_string()))?;
598
599        // NIP-30: resolve `:shortcode:` so the edit carries emoji image tags.
600        let emoji_tags = emoji_packs::resolve_outbound_emoji_tags(new_content);
601
602        let mut builder = EventBuilder::new(
603            Kind::from_u16(stored_event::event_kind::MESSAGE_EDIT),
604            new_content,
605        ).tag(Tag::event(reference_event));
606        for et in &emoji_tags {
607            builder = builder.tag(Tag::custom(
608                TagKind::custom("emoji"),
609                [et.shortcode.clone(), et.url.clone()],
610            ));
611        }
612        let rumor = builder.build(my_public_key);
613        let edit_id = rumor.id.ok_or(VectorError::Other("Failed to get edit rumor ID".into()))?.to_hex();
614        let edit_ts_ms = rumor.created_at.as_secs() * 1000;
615
616        // Optimistic local echo + best-effort persistence.
617        let msg_for_emit = {
618            let mut st = state::STATE.lock().await;
619            st.update_message_in_chat(to_npub, message_id, |msg| {
620                msg.apply_edit(new_content.to_string(), edit_ts_ms, emoji_tags.clone());
621                msg.preview_metadata = None;
622            })
623        };
624        if let Some(msg) = msg_for_emit {
625            traits::emit_event_json("message_update", serde_json::json!({
626                "old_id": message_id, "message": &msg, "chat_id": to_npub
627            }));
628            if let Ok(db_chat_id) = db::id_cache::get_chat_id_by_identifier(to_npub) {
629                let _ = db::events::save_edit_event(
630                    &edit_id, message_id, new_content, &emoji_tags, db_chat_id, None, &my_npub,
631                ).await;
632            }
633        }
634
635        inbox_relays::send_gift_wrap(&client, &receiver_pubkey, rumor.clone(), [])
636            .await.map_err(VectorError::Other)?;
637
638        let self_wrap_client = client.clone();
639        let self_wrap_session = state::SessionGuard::capture();
640        tokio::spawn(async move {
641            if !self_wrap_session.is_valid() { return; }
642            let _ = self_wrap_client.gift_wrap(&my_public_key, rumor, []).await;
643        });
644
645        Ok(edit_id)
646    }
647
648    /// Delete a DM you sent (NIP-09 over the retained gift-wrap keys).
649    pub async fn delete_dm(&self, message_id: &str) -> Result<deletion::DeleteOutcome> {
650        use nostr_sdk::prelude::*;
651        let rumor_id = EventId::from_hex(message_id).map_err(|e| VectorError::Nostr(e.to_string()))?;
652        deletion::delete_own_dm(&rumor_id).await.map_err(VectorError::Other)
653    }
654
655    /// Get chats from the in-memory state.
656    pub async fn get_chats(&self) -> Vec<SerializableChat> {
657        let state = state::STATE.lock().await;
658        state.chats.iter()
659            .map(|c| c.to_serializable_with_last_n(1, &state.interner))
660            .collect()
661    }
662
663    /// Get messages for a chat (paginated).
664    pub async fn get_messages(&self, chat_id: &str, limit: usize, offset: usize) -> Vec<Message> {
665        let state = state::STATE.lock().await;
666        if let Some(chat) = state.get_chat(chat_id) {
667            let msgs = chat.get_all_messages(&state.interner);
668            let start = offset.min(msgs.len());
669            let end = (offset + limit).min(msgs.len());
670            msgs[start..end].to_vec()
671        } else {
672            Vec::new()
673        }
674    }
675
676    /// Get a profile by npub.
677    pub async fn get_profile(&self, npub: &str) -> Option<SlimProfile> {
678        let state = state::STATE.lock().await;
679        state.get_profile(npub)
680            .map(|p| SlimProfile::from_profile(p, &state.interner))
681    }
682
683    /// Fetch a profile's metadata and status from relays.
684    pub async fn load_profile(&self, npub: &str) -> bool {
685        profile::sync::load_profile(npub.to_string(), &NoOpProfileSyncHandler).await
686    }
687
688    /// Update the current user's profile metadata and broadcast to relays.
689    pub async fn update_profile(&self, name: &str, avatar: &str, banner: &str, about: &str) -> bool {
690        profile::sync::update_profile(
691            name.to_string(), avatar.to_string(), banner.to_string(), about.to_string(),
692            &NoOpProfileSyncHandler,
693        ).await
694    }
695
696    /// Like [`update_profile`](Self::update_profile) but marks the profile as a bot (`bot: true` in
697    /// the metadata). The SDK uses this for every bot; build human clients on `update_profile`.
698    pub async fn update_bot_profile(&self, name: &str, avatar: &str, banner: &str, about: &str) -> bool {
699        profile::sync::update_bot_profile(
700            name.to_string(), avatar.to_string(), banner.to_string(), about.to_string(),
701            &NoOpProfileSyncHandler,
702        ).await
703    }
704
705    /// Update the current user's status and broadcast to relays.
706    pub async fn update_status(&self, status: &str) -> bool {
707        profile::sync::update_status(status.to_string()).await
708    }
709
710    /// Upload an image file to Blossom **unencrypted** and return its public URL — for avatars,
711    /// banners, and other images other clients must fetch directly. (The opposite of
712    /// [`send_file`](Self::send_file)'s encrypted attachments.) Pass the URL to [`update_profile`].
713    ///
714    /// [`update_profile`]: Self::update_profile
715    pub async fn upload_public_image(&self, file_path: &str) -> Result<String> {
716        let path = std::path::Path::new(file_path);
717        let bytes = std::fs::read(path).map_err(VectorError::Io)?;
718        if bytes.is_empty() {
719            return Err(VectorError::Other("Empty image file".into()));
720        }
721        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("bin").to_lowercase();
722        let mime = crate::crypto::mime_from_extension(&extension);
723        let client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
724        let signer = client
725            .signer()
726            .await
727            .map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
728        let servers = crate::blossom_servers::compute_enabled_servers();
729        if servers.is_empty() {
730            return Err(VectorError::Other("No Blossom servers configured".into()));
731        }
732        crate::blossom::upload_blob_with_failover(signer, servers, std::sync::Arc::new(bytes), Some(mime))
733            .await
734            .map_err(VectorError::Other)
735    }
736
737    /// Block a user by npub.
738    pub async fn block_user(&self, npub: &str) -> bool {
739        profile::sync::block_user(npub.to_string(), &NoOpProfileSyncHandler).await
740    }
741
742    /// Unblock a user by npub.
743    pub async fn unblock_user(&self, npub: &str) -> bool {
744        profile::sync::unblock_user(npub.to_string(), &NoOpProfileSyncHandler).await
745    }
746
747    /// Set a nickname for a profile.
748    pub async fn set_nickname(&self, npub: &str, nickname: &str) -> bool {
749        profile::sync::set_nickname(npub.to_string(), nickname.to_string(), &NoOpProfileSyncHandler).await
750    }
751
752    /// Get all blocked profiles.
753    pub async fn get_blocked_users(&self) -> Vec<SlimProfile> {
754        profile::sync::get_blocked_users().await
755    }
756
757    /// Queue a profile for background sync.
758    pub fn queue_profile_sync(&self, npub: &str, priority: SyncPriority) {
759        profile::sync::queue_profile_sync(npub.to_string(), priority, false);
760    }
761
762    /// Get the current user's npub.
763    pub fn my_npub(&self) -> Option<String> {
764        state::my_public_key()
765            .and_then(|pk| ToBech32::to_bech32(&pk).ok())
766    }
767
768    // === Communities (headless) ===
769    // The GUI's Tauri commands carry optimistic-echo + emit machinery a headless client
770    // doesn't need; these are the lean equivalents over the same `community::service` layer,
771    // so a CLI / agent can join, read, post, and sync a Community.
772
773    /// List every Community held locally (owned or joined), each with its channels.
774    pub async fn list_communities(&self) -> Vec<serde_json::Value> {
775        use crate::community::ConcordProtocol;
776        let ids = crate::db::community::list_community_ids().unwrap_or_default();
777        let mut out = Vec::new();
778        for id in ids {
779            // Dual-stack: dispatch each held community by its stored protocol.
780            match crate::db::community::community_protocol(&id).ok().flatten() {
781                Some(ConcordProtocol::V2) => {
782                    if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
783                        let me = state::my_public_key();
784                        let is_owner = me.is_some_and(|m| c.owner().is_ok_and(|o| o == m));
785                        out.push(serde_json::json!({
786                            "community_id": crate::simd::hex::bytes_to_hex_32(&c.identity.community_id.0),
787                            "version": 2,
788                            "name": c.name,
789                            "description": c.description,
790                            "is_owner": is_owner,
791                            "channels": c.channels.iter()
792                                .map(|ch| serde_json::json!({ "channel_id": crate::simd::hex::bytes_to_hex_32(&ch.id.0), "name": ch.name, "private": ch.private }))
793                                .collect::<Vec<_>>(),
794                        }));
795                    }
796                }
797                _ => {
798                    if let Ok(Some(c)) = crate::db::community::load_community(&id) {
799                        out.push(serde_json::json!({
800                            "community_id": c.id.to_hex(),
801                            "version": 1,
802                            "name": c.name,
803                            "description": c.description,
804                            "is_owner": crate::community::service::is_proven_owner(&c),
805                            "channels": c.channels.iter()
806                                .map(|ch| serde_json::json!({ "channel_id": ch.id.to_hex(), "name": ch.name }))
807                                .collect::<Vec<_>>(),
808                        }));
809                    }
810                }
811            }
812        }
813        out
814    }
815
816    /// Create a fresh **Concord v2** community owned by the local identity (the
817    /// SDK's default; the GUI's `create_community` stays v1 during the migration
818    /// window). Mints the self-certifying id + genesis, persists, publishes, and
819    /// registers each channel as a chat. Returns a `version: 2` JSON summary.
820    pub async fn create_community_v2(&self, name: &str) -> Result<serde_json::Value> {
821        use crate::community::{v2::service as v2, transport::LiveTransport};
822        let relays: Vec<String> = crate::state::active_trusted_relays()
823            .await
824            .iter()
825            .map(|s| s.to_string())
826            .collect();
827        if relays.is_empty() {
828            return Err(VectorError::Other("no relays available to host the Community".into()));
829        }
830        let session = state::SessionGuard::capture();
831        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
832        let community = v2::create_community(&transport, name, relays, None)
833            .await
834            .map_err(VectorError::Other)?;
835        self.register_v2_chats(&community, &session).await;
836        // Start streaming this community's planes right away.
837        if let Some(client) = state::nostr_client() {
838            crate::community::v2::realtime::refresh_subscription(&client).await;
839        }
840        Ok(Self::v2_summary(&community))
841    }
842
843    /// If `channel_id` belongs to a locally-held **v2** community, its
844    /// `CommunityId`; `Ok(None)` for a v1 channel or unknown. The routing key for
845    /// every dual-stack message op — a DB read error PROPAGATES (fail-closed)
846    /// instead of silently routing a v2 channel down the v1 path on a transient
847    /// failure.
848    fn v2_community_for_channel(&self, channel_id: &str) -> Result<Option<crate::community::CommunityId>> {
849        use crate::community::ConcordProtocol;
850        let Some(cid_hex) = crate::db::community::community_id_for_channel(channel_id).map_err(VectorError::Other)? else {
851            return Ok(None);
852        };
853        let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
854        Ok(match crate::db::community::community_protocol(&cid).map_err(VectorError::Other)? {
855            Some(ConcordProtocol::V2) => Some(cid),
856            _ => None,
857        })
858    }
859
860    /// The `version: 2` JSON summary the SDK/facade hands back for a v2 community.
861    fn v2_summary(community: &crate::community::v2::community::CommunityV2) -> serde_json::Value {
862        let me = state::my_public_key();
863        let is_owner = me.is_some_and(|m| community.owner().is_ok_and(|o| o == m));
864        serde_json::json!({
865            "community_id": crate::simd::hex::bytes_to_hex_32(&community.identity.community_id.0),
866            "version": 2,
867            "name": community.name,
868            "description": community.description,
869            "is_owner": is_owner,
870            "channels": community.channels.iter()
871                .map(|c| serde_json::json!({ "channel_id": crate::simd::hex::bytes_to_hex_32(&c.id.0), "name": c.name, "private": c.private }))
872                .collect::<Vec<_>>(),
873        })
874    }
875
876    /// Register each of a v2 community's channels as a chat row (so it surfaces in
877    /// the chat list / `communities()`), mirroring the v1 create path. `session`
878    /// is captured by the caller BEFORE its network I/O, so this STATE write is
879    /// skipped if the account swapped mid-flight (else we'd write A's community
880    /// into B's in-memory chats).
881    pub async fn register_v2_chats(&self, community: &crate::community::v2::community::CommunityV2, session: &state::SessionGuard) {
882        let owner_npub = community.owner().ok().and_then(|p| ToBech32::to_bech32(&p).ok());
883        let me = state::my_public_key();
884        let is_owner = me.is_some_and(|m| community.owner().is_ok_and(|o| o == m));
885        let id_hex = crate::simd::hex::bytes_to_hex_32(&community.identity.community_id.0);
886        // The chat list shows ONE row per community — the primary channel under the
887        // community's metadata (v1-group parity; multi-channel UI is a later cut).
888        let Some(primary) = community.primary_channel() else { return };
889        let primary_hex = crate::simd::hex::bytes_to_hex_32(&primary.id.0);
890        let sibling_ids: Vec<String> = community
891            .channels
892            .iter()
893            .filter(|c| c.id.0 != primary.id.0)
894            .map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0))
895            .collect();
896        let slim = {
897            let mut st = state::STATE.lock().await;
898            if !session.is_valid() {
899                return; // account swapped during the join/create — don't write into the new one.
900            }
901            st.upsert_community_chat(
902                &primary_hex,
903                &community.name,
904                community.description.as_deref().unwrap_or(""),
905                &id_hex,
906                is_owner,
907                community.icon.is_some(),
908                owner_npub.as_deref(),
909                Some(community.created_at_ms),
910                community.dissolved,
911                crate::community::ConcordProtocol::V2,
912            );
913            // Sibling-channel rows the message persist auto-created are bare
914            // anchors (their DB rows keep the history's FK) — never surfaced.
915            st.chats.retain(|c| !sibling_ids.contains(&c.id));
916            st.chats
917                .iter()
918                .find(|c| c.id == primary_hex)
919                .map(|chat| crate::db::chats::SlimChatDB::from_chat(chat, &st.interner))
920        };
921        // Persist the row so a fresh boot reloads the community's name/metadata
922        // instead of the bare auto-created anchor. Session re-check: don't write
923        // account A's row into a swapped-in account B's DB.
924        if !session.is_valid() {
925            return;
926        }
927        if let Some(slim) = slim {
928            let _ = crate::db::chats::save_slim_chat(&slim);
929        }
930    }
931
932    /// Join a Community from a public invite URL (`vectorapp.io/invite#...`). Fetches the
933    /// token-encrypted bundle, persists the member-view Community, and registers its channels
934    /// as chats. Returns a JSON summary.
935    pub async fn join_community(&self, invite_url: &str) -> Result<serde_json::Value> {
936        use crate::community::{public_invite, service, transport::LiveTransport};
937        // Dual-stack: a v2 link is `…/invite/<naddr>#<fragment>` (a naddr in the
938        // path); a v1 link is `…/invite#<base64url>` (fragment only). Try the v2
939        // parser first — it only succeeds on the v2 shape — then fall through to v1.
940        if crate::community::v2::invite::parse_invite_link(invite_url).is_ok() {
941            let session = state::SessionGuard::capture();
942            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
943            let community = crate::community::v2::service::accept_public_link(&transport, invite_url)
944                .await
945                .map_err(VectorError::Other)?;
946            self.register_v2_chats(&community, &session).await;
947            if let Some(client) = state::nostr_client() {
948                crate::community::v2::realtime::refresh_subscription(&client).await;
949            }
950            // Seed the membership store post-join. With a live listen the follow
951            // worker does it (and SURFACES the folded joins as presence lines —
952            // the joiner sees the room's history, own join included); headless
953            // callers seed directly (membership only, no feed to surface).
954            if crate::community::v2::realtime::follow_worker_running() {
955                crate::community::v2::realtime::enqueue_follow(community.id());
956            } else {
957                let seed_session = state::SessionGuard::capture();
958                let seed_community = community.clone();
959                tokio::spawn(async move {
960                    if !seed_session.is_valid() {
961                        return;
962                    }
963                    let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(20));
964                    if matches!(
965                        crate::community::v2::service::sync_guestbook(&transport, &seed_community, &seed_session).await,
966                        Ok(fresh) if !fresh.is_empty()
967                    ) {
968                        let cid_hex = crate::simd::hex::bytes_to_hex_32(&seed_community.id().0);
969                        emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
970                    }
971                });
972            }
973            return Ok(Self::v2_summary(&community));
974        }
975        let (relays, token) = public_invite::parse_invite_url(invite_url)
976            .map_err(|e| VectorError::Other(e.to_string()))?;
977        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
978        let bundle = service::fetch_public_invite(&transport, &relays, &token)
979            .await
980            .map_err(VectorError::Other)?;
981        let now = std::time::SystemTime::now()
982            .duration_since(std::time::UNIX_EPOCH)
983            .map(|d| d.as_secs())
984            .unwrap_or(0);
985        let community = service::accept_public_invite(&bundle, now).map_err(VectorError::Other)?;
986        // Attribute our join presence to the link we used (creator + label) so the owner's per-link
987        // counter ticks. Mirrors the desktop public-join path.
988        let attribution = bundle.creator_npub.clone().map(|by| (by, bundle.label.clone()));
989        self.finalize_member_join(community, &transport, attribution).await
990    }
991
992    /// List the parked private invites (giftwrapped) awaiting acceptance. Each entry is the
993    /// community id, its name (from the stored bundle), and the inviter's npub.
994    pub fn list_pending_invites(&self) -> Result<Vec<serde_json::Value>> {
995        let rows = crate::db::community::list_pending_invites().map_err(VectorError::Other)?;
996        Ok(rows.iter().map(|p| {
997            // A v2 bundle carries owner_salt/community_root and self-certifies its
998            // owner; a successful (validating) v2 parse means the modern protocol.
999            if let Ok(v2) = crate::community::v2::invite::CommunityInvite::from_bundle_json(&p.bundle_json) {
1000                serde_json::json!({
1001                    "community_id": p.community_id,
1002                    "name": v2.name,
1003                    "inviter_npub": p.inviter_npub,
1004                    "version": 2,
1005                })
1006            } else {
1007                let name = crate::community::invite::CommunityInvite::from_json(&p.bundle_json)
1008                    .ok().map(|i| i.name).unwrap_or_default();
1009                serde_json::json!({
1010                    "community_id": p.community_id,
1011                    "name": name,
1012                    "inviter_npub": p.inviter_npub,
1013                    "version": 1,
1014                })
1015            }
1016        }).collect())
1017    }
1018
1019    /// Accept a PARKED private invite by community id: rebuild the member-view Community from the stored
1020    /// bundle, finalize the join exactly like a public link, then drop the pending row. Mirrors the
1021    /// desktop's consent-then-join for an invite delivered over a gift wrap.
1022    pub async fn accept_pending_invite(&self, community_id: &str) -> Result<serde_json::Value> {
1023        use crate::community::transport::LiveTransport;
1024        let bundle_json = crate::db::community::get_pending_invite(community_id)
1025            .map_err(VectorError::Other)?
1026            .ok_or_else(|| VectorError::Other(format!("no pending invite for {community_id}")))?;
1027        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1028
1029        // Dual-stack: a validating v2 bundle parse means a v2 Direct Invite.
1030        if crate::community::v2::invite::CommunityInvite::from_bundle_json(&bundle_json).is_ok() {
1031            let session = state::SessionGuard::capture();
1032            // The inviter's hex (parked at receive) attributes the Guestbook Join.
1033            let inviter = crate::db::community::list_pending_invites()
1034                .ok()
1035                .and_then(|rows| rows.into_iter().find(|p| p.community_id == community_id).map(|p| p.inviter_npub));
1036            // On failure the parked row is LEFT INTACT for retry — we must NOT auto-delete
1037            // on a verify failure: the multi-relay transport launders an unreachable-relay
1038            // error into an empty fetch, which yields the same "could not verify" as a
1039            // forged root (and a control-plane flood does too), so an auto-delete would
1040            // erase a GENUINE invite on a transient blip or an attacker's flood. A
1041            // pre-planted forged-root bundle (deferred protocol residual) is instead
1042            // cleared by the user declining it.
1043            let community = crate::community::v2::service::accept_parked_invite(&transport, &bundle_json, inviter.as_deref())
1044                .await
1045                .map_err(VectorError::Other)?;
1046            if !session.is_valid() {
1047                return Err(VectorError::Other("account changed during join".into()));
1048            }
1049            self.register_v2_chats(&community, &session).await;
1050            if let Some(client) = state::nostr_client() {
1051                crate::community::v2::realtime::refresh_subscription(&client).await;
1052            }
1053            crate::community::v2::realtime::enqueue_follow(community.id());
1054            let _ = crate::db::community::delete_pending_invite(community_id);
1055            return Ok(Self::v2_summary(&community));
1056        }
1057
1058        // v1 route.
1059        use crate::community::invite::{accept_invite, CommunityInvite};
1060        let invite = CommunityInvite::from_json(&bundle_json).map_err(VectorError::Other)?;
1061        let community = accept_invite(&invite).map_err(VectorError::Other)?;
1062        // Private invites carry no public-link label; the inviter attribution metric is link-only.
1063        let summary = self.finalize_member_join(community, &transport, None).await?;
1064        let _ = crate::db::community::delete_pending_invite(community_id);
1065        Ok(summary)
1066    }
1067
1068    /// Shared finalization for joining a Community as a member — public link OR accepted private invite.
1069    /// Walks any base rekey, folds the LATEST control plane (so the joiner sees current metadata, not
1070    /// the bundle's genesis snapshot), refuses if banned, registers the channels as chats, and announces
1071    /// presence. Returns the JSON summary.
1072    pub(crate) async fn finalize_member_join<T: crate::community::transport::Transport + ?Sized>(
1073        &self,
1074        community: crate::community::Community,
1075        transport: &T,
1076        attribution: Option<(String, Option<String>)>,
1077    ) -> Result<serde_json::Value> {
1078        use crate::community::service;
1079        // Persist the member-view row up front: the catch-up, the control fold, and chat registration all
1080        // read it back from the DB. A private bundle (unlike a public one with a preview) arrives with no
1081        // display metadata, so nothing else would have saved it. UPSERT — re-saving a public join is a no-op.
1082        crate::db::community::save_community(&community).map_err(VectorError::Other)?;
1083        // The bundle's root can predate a base rotation, so walk any rekey first (no-op if none) — then
1084        // re-load so the control fold + registration happen at the CURRENT epoch.
1085        if let Ok(c) = service::catch_up_server_root(transport, &community).await {
1086            if c.removed {
1087                let _ = crate::db::community::delete_community(&community.id.to_hex());
1088                return Err(VectorError::Other("you have been removed from this community".into()));
1089            }
1090        }
1091        let community = crate::db::community::load_community(&community.id)
1092            .map_err(VectorError::Other)?
1093            .unwrap_or(community);
1094        // Fold the LATEST control plane before we register anything — the joiner should see the current
1095        // name/description/roster/mode immediately, not a stale snapshot. Banlist first: an honest client
1096        // REFUSES to join if this npub is banned (and the just-saved community is torn back down).
1097        let _ = service::fetch_and_apply_control(transport, &community).await;
1098        if service::am_i_banned(&community) {
1099            let _ = crate::db::community::delete_community(&community.id.to_hex());
1100            return Err(VectorError::Other("you are banned from this community".into()));
1101        }
1102        // Re-load so the chat we register + the summary we return carry the freshly-folded latest metadata.
1103        let community = crate::db::community::load_community(&community.id)
1104            .map_err(VectorError::Other)?
1105            .unwrap_or(community);
1106        let owner_npub = community
1107            .owner_attestation
1108            .as_ref()
1109            .and_then(|att| crate::community::owner::verify_owner_attestation(att, &community.id.to_hex()))
1110            .and_then(|pk| ToBech32::to_bech32(&pk).ok());
1111        {
1112            let created_at_ms = crate::db::community::community_created_at_ms(&community.id);
1113            let mut st = state::STATE.lock().await;
1114            for ch in &community.channels {
1115                st.upsert_community_chat(
1116                    &ch.id.to_hex(),
1117                    &community.name,
1118                    community.description.as_deref().unwrap_or(""),
1119                    &community.id.to_hex(),
1120                    crate::community::service::is_proven_owner(&community),
1121                    community.icon.is_some(),
1122                    owner_npub.as_deref(),
1123                    created_at_ms,
1124                    community.dissolved,
1125                    crate::community::ConcordProtocol::V1,
1126                );
1127            }
1128        }
1129        // Best-effort join announcement (kind 3306) into the primary channel so honest peers
1130        // see us in their member list even before we post. Failure must not fail the join.
1131        if let Some(primary) = community.channels.first() {
1132            let _ = service::publish_presence(transport, &community, primary, true, attribution).await;
1133        }
1134        Ok(serde_json::json!({
1135            "community_id": community.id.to_hex(),
1136            "version": 1,
1137            "name": community.name,
1138            "channels": community.channels.iter()
1139                .map(|c| serde_json::json!({ "channel_id": c.id.to_hex(), "name": c.name }))
1140                .collect::<Vec<_>>(),
1141        }))
1142    }
1143
1144    /// Create a Community (single "general" channel) on the default trusted relays. Signs the
1145    /// owner attestation with this identity (so the creator is the proven owner), registers the
1146    /// channel as a chat, and returns a JSON summary.
1147    pub async fn create_community(&self, name: &str) -> Result<serde_json::Value> {
1148        use crate::community::{service, transport::LiveTransport};
1149        let relays: Vec<String> = crate::state::active_trusted_relays()
1150            .await
1151            .iter()
1152            .map(|s| s.to_string())
1153            .collect();
1154        if relays.is_empty() {
1155            return Err(VectorError::Other("no relays available to host the Community".into()));
1156        }
1157        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1158        let community = service::create_community(&transport, name, "general", relays)
1159            .await
1160            .map_err(VectorError::Other)?;
1161        let owner_npub = community
1162            .owner_attestation
1163            .as_ref()
1164            .and_then(|att| crate::community::owner::verify_owner_attestation(att, &community.id.to_hex()))
1165            .and_then(|pk| ToBech32::to_bech32(&pk).ok());
1166        {
1167            let created_at_ms = crate::db::community::community_created_at_ms(&community.id);
1168            let mut st = state::STATE.lock().await;
1169            for ch in &community.channels {
1170                st.upsert_community_chat(
1171                    &ch.id.to_hex(),
1172                    &community.name,
1173                    community.description.as_deref().unwrap_or(""),
1174                    &community.id.to_hex(),
1175                    crate::community::service::is_proven_owner(&community),
1176                    community.icon.is_some(),
1177                    owner_npub.as_deref(),
1178                    created_at_ms,
1179                    community.dissolved,
1180                    crate::community::ConcordProtocol::V1,
1181                );
1182            }
1183        }
1184        Ok(serde_json::json!({
1185            "community_id": community.id.to_hex(),
1186            "version": 1,
1187            "name": community.name,
1188            "channels": community.channels.iter()
1189                .map(|c| serde_json::json!({ "channel_id": c.id.to_hex(), "name": c.name }))
1190                .collect::<Vec<_>>(),
1191        }))
1192    }
1193
1194    /// Mint a public invite link for a Community this identity owns. Returns the shareable URL.
1195    pub async fn create_public_invite(&self, community_id: &str) -> Result<String> {
1196        use crate::community::{service, transport::LiveTransport, CommunityId};
1197        if community_id.len() != 64 {
1198            return Err(VectorError::Other("malformed community id".into()));
1199        }
1200        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
1201        // Dual-stack: mint a v2 link for a v2 community (naddr#fragment).
1202        if let Some(Some(crate::community::ConcordProtocol::V2)) =
1203            crate::db::community::community_protocol(&cid).ok()
1204        {
1205            let community = crate::db::community::load_community_v2(&cid)
1206                .map_err(VectorError::Other)?
1207                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1208            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1209            // v2 `build_invite_url` appends its own `/invite/<naddr>`, so pass the
1210            // bare domain (strip the `/invite` the v1 constant carries).
1211            let base = crate::community::public_invite::INVITE_URL_BASE.trim_end_matches("/invite");
1212            let minted = crate::community::v2::service::mint_public_link(&transport, &community, base, None, None)
1213                .await
1214                .map_err(VectorError::Other)?;
1215            return Ok(minted.url);
1216        }
1217        let community = crate::db::community::load_community(&CommunityId(
1218            crate::simd::hex::hex_to_bytes_32(community_id),
1219        ))
1220        .map_err(VectorError::Other)?
1221        .ok_or_else(|| VectorError::Other("community not found".into()))?;
1222        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1223        let (_token, url) = service::create_public_invite(&transport, &community, None, None)
1224            .await
1225            .map_err(VectorError::Other)?;
1226        Ok(url)
1227    }
1228
1229    /// Send a PRIVATE invite: gift-wrap this Community's invite bundle directly to an npub over a NIP-17
1230    /// DM (the same transport as a regular DM). The invitee parks it pending consent (accept_pending_invite).
1231    /// Requires CREATE_INVITE; a banned npub can't be re-invited. Returns the wrap's event id + relays.
1232    pub async fn invite_to_community(&self, community_id: &str, invitee_npub: &str) -> Result<serde_json::Value> {
1233        use crate::community::{service, CommunityId};
1234        use crate::sending::{send_rumor_dm, NoOpSendCallback, SendCallback, SendConfig};
1235
1236        let session = crate::state::SessionGuard::capture();
1237        let my_pk = crate::state::my_public_key()
1238            .ok_or_else(|| VectorError::Other("Public key not set".into()))?;
1239
1240        if community_id.len() != 64 {
1241            return Err(VectorError::Other("malformed community id".into()));
1242        }
1243        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
1244        // Dual-stack: a v2 community sends a Direct Invite (3313 giftwrap).
1245        // DELIBERATELY ungated, unlike v1's CREATE_INVITE + banlist pre-check: a
1246        // Direct Invite is an ungateable key handoff (CORD-05 §6 — "any keyholder
1247        // can whisper keys"), so any member may extend one; the real access cut is
1248        // the rekey, not a permission on inviting.
1249        if let Some(Some(crate::community::ConcordProtocol::V2)) =
1250            crate::db::community::community_protocol(&cid).ok()
1251        {
1252            let community = crate::db::community::load_community_v2(&cid)
1253                .map_err(VectorError::Other)?
1254                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1255            let recipient = nostr_sdk::prelude::PublicKey::parse(invitee_npub)
1256                .map_err(|e| VectorError::Other(format!("bad invitee npub: {e}")))?;
1257            let client = crate::state::nostr_client().ok_or_else(|| VectorError::Other("Not connected".into()))?;
1258            // Gift-wrap the 3313 Direct-Invite rumor (the bundle JSON) to the RECIPIENT'S
1259            // inbox relays (kind-10050) — a not-yet-member sees it on their DM sub;
1260            // the community relays wouldn't reach them. `#k=3313` per CORD-05 §6.
1261            let bundle = crate::community::v2::service::bundle_of(&community, Some(my_pk), None, None);
1262            let bundle_json = serde_json::to_string(&bundle).map_err(|e| VectorError::Other(e.to_string()))?;
1263            let rumor = nostr_sdk::EventBuilder::new(
1264                nostr_sdk::Kind::Custom(crate::community::v2::kind::DIRECT_INVITE),
1265                bundle_json,
1266            )
1267            .build(my_pk);
1268            let k_tag = nostr_sdk::Tag::custom(
1269                nostr_sdk::TagKind::Custom("k".into()),
1270                [crate::community::v2::kind::DIRECT_INVITE.to_string()],
1271            );
1272            if !session.is_valid() {
1273                return Err(VectorError::Other("account changed".into()));
1274            }
1275            crate::inbox_relays::send_gift_wrap(&client, &recipient, rumor, [k_tag])
1276                .await
1277                .map_err(VectorError::Other)?;
1278            return Ok(serde_json::json!({ "invited": invitee_npub, "version": 2 }));
1279        }
1280        let community = crate::db::community::load_community(&CommunityId(
1281            crate::simd::hex::hex_to_bytes_32(community_id),
1282        ))
1283        .map_err(VectorError::Other)?
1284        .ok_or_else(|| VectorError::Other("community not found".into()))?;
1285
1286        if !service::caller_has_permission(&community, crate::community::roles::Permissions::CREATE_INVITE) {
1287            return Err(VectorError::Other("You need the create-invite permission to invite someone".into()));
1288        }
1289        let invitee_hex = nostr_sdk::PublicKey::parse(invitee_npub)
1290            .map_err(|_| VectorError::Other("invalid npub".into()))?
1291            .to_hex();
1292        if crate::db::community::get_community_banlist(community_id)
1293            .map_err(VectorError::Other)?
1294            .iter()
1295            .any(|b| b == &invitee_hex)
1296        {
1297            return Err(VectorError::Other("That member is banned from this community and can't be invited".into()));
1298        }
1299
1300        // The bundle is built from purely local state; bail if the account swapped before the gift-wrap.
1301        if !session.is_valid() {
1302            return Err(VectorError::Other("account changed during invite".into()));
1303        }
1304
1305        let rumor = crate::community::invite::build_invite_rumor(&community, my_pk).map_err(VectorError::Other)?;
1306        let pending_id = format!("community-invite-{}", community_id);
1307        // self_send=false: the owner already holds the Community; the inbound guard would drop the echo.
1308        let config = SendConfig { self_send: false, ..SendConfig::gui() };
1309        let callback: Arc<dyn SendCallback> = Arc::new(NoOpSendCallback);
1310
1311        let result = send_rumor_dm(invitee_npub, &pending_id, rumor, &config, callback)
1312            .await
1313            .map_err(VectorError::Other)?;
1314
1315        Ok(serde_json::json!({
1316            "community_id": community_id,
1317            "invitee": invitee_npub,
1318            "wrap_event_id": result.event_id,
1319        }))
1320    }
1321
1322    /// The public invite links this account minted for a Community (to list + revoke). Each carries
1323    /// the hex `token` (the link secret) needed by [`Self::revoke_public_invite`]. A local read for
1324    /// both protocols — links minted on this device (a v2 mint also syncs the cross-device 13303
1325    /// record; v2 `join_count` is not yet tracked and is always 0).
1326    pub fn list_public_invites(&self, community_id: &str) -> Result<Vec<crate::db::community::PublicInviteRecord>> {
1327        crate::db::community::list_public_invites(community_id).map_err(VectorError::Other)
1328    }
1329
1330    /// Revoke a public invite link by its hex token. Retiring the LAST active link flips the Community to
1331    /// Private, which re-founds (rotates the base key + every channel key) to cut link-joined lurkers.
1332    /// Idempotent: a token this account doesn't hold is a no-op. Needs a local key when the revoke triggers
1333    /// the privatize rekey (a bunker account can't rotate).
1334    pub async fn revoke_public_invite(&self, community_id: &str, token: &str) -> Result<()> {
1335        use crate::community::{service, transport::LiveTransport, CommunityId};
1336        if community_id.len() != 64 {
1337            return Err(VectorError::Other("malformed community id".into()));
1338        }
1339        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
1340        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(20));
1341        // Dual-stack: a v2 link is retired by its 16-byte token hex (re-post the
1342        // coordinate as a tombstone + tombstone the 13303 entry + refresh the Registry).
1343        if let Some(Some(crate::community::ConcordProtocol::V2)) = crate::db::community::community_protocol(&cid).ok() {
1344            let community = crate::db::community::load_community_v2(&cid)
1345                .map_err(VectorError::Other)?
1346                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1347            return crate::community::v2::service::revoke_public_link(&transport, &community, token)
1348                .await
1349                .map_err(VectorError::Other);
1350        }
1351        let token_bytes = crate::simd::hex::hex_to_bytes_32(token);
1352        let community = crate::db::community::load_community(&cid)
1353            .map_err(VectorError::Other)?
1354            .ok_or_else(|| VectorError::Other("community not found".into()))?;
1355        service::revoke_public_invite(&transport, &community, &token_bytes)
1356            .await
1357            .map_err(VectorError::Other)
1358    }
1359
1360    /// Post a text message to a Community channel. Returns the message id (the inner id).
1361    pub async fn send_community_message(
1362        &self,
1363        channel_id: &str,
1364        content: &str,
1365        replied_to: Option<&str>,
1366    ) -> Result<String> {
1367        use crate::community::{envelope, inbound, service, transport::LiveTransport};
1368        // Dual-stack: route by the owning community's stored protocol.
1369        if let Some(id) = self.v2_community_for_channel(channel_id)? {
1370            let community = crate::db::community::load_community_v2(&id)
1371                .map_err(VectorError::Other)?
1372                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1373            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
1374            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1375            // The NIP-C7 q tag's author slot is a SHOULD — best-effort from the
1376            // held message, empty (= unknown) when the parent isn't in memory.
1377            let reply = match replied_to.filter(|r| !r.is_empty()) {
1378                Some(parent_id) => {
1379                    let author_hex = {
1380                        let st = state::STATE.lock().await;
1381                        st.find_message(parent_id)
1382                            .and_then(|(_, m)| m.npub.as_deref().and_then(|n| nostr_sdk::prelude::PublicKey::parse(n).ok()))
1383                            .map(|pk| pk.to_hex())
1384                            .unwrap_or_default()
1385                    };
1386                    Some((parent_id.to_string(), author_hex))
1387                }
1388                None => None,
1389            };
1390            let reply_ref = reply.as_ref().map(|(id, author)| (id.as_str(), author.as_str()));
1391            // NIP-30: resolve `:shortcode:` against subscribed packs so the rumor
1392            // carries `["emoji", ...]` pairs — parity with the v1 inner event.
1393            let emoji_owned = crate::emoji_packs::resolve_outbound_emoji_tags(content);
1394            let emoji_pairs: Vec<(&str, &str)> = emoji_owned.iter().map(|t| (t.shortcode.as_str(), t.url.as_str())).collect();
1395            return crate::community::v2::service::send_chat_message(&transport, &community, &ch, content, reply_ref, &emoji_pairs, vec![])
1396                .await
1397                .map_err(VectorError::Other);
1398        }
1399        let (community, channel) = self.resolve_channel(channel_id)?;
1400        let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1401        let reply = replied_to.filter(|r| !r.is_empty());
1402        let ms = std::time::SystemTime::now()
1403            .duration_since(std::time::UNIX_EPOCH)
1404            .map(|d| d.as_millis() as u64)
1405            .unwrap_or(0);
1406        let unsigned = envelope::build_inner_typed(
1407            author_pk,
1408            &channel.id,
1409            channel.epoch,
1410            crate::stored_event::event_kind::COMMUNITY_MESSAGE,
1411            content,
1412            ms,
1413            reply,
1414            &[],
1415        );
1416        let message_id = unsigned.id.ok_or_else(|| VectorError::Other("inner event has no id".into()))?.to_hex();
1417        let client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1418        let signer = client.signer().await.map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
1419        let inner = unsigned.sign(&signer).await.map_err(|e| VectorError::Other(format!("sign: {e}")))?;
1420        let session = state::SessionGuard::capture();
1421        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1422        let outer = service::send_signed_message(&transport, &community, &channel, &inner)
1423            .await
1424            .map_err(VectorError::Other)?;
1425        // Local echo so get_messages reflects the send (the relay echo dedups on inner id).
1426        // A swap during the publish must not echo account A's message into account B.
1427        if !session.is_valid() {
1428            return Ok(message_id);
1429        }
1430        let echoed = {
1431            let mut st = state::STATE.lock().await;
1432            inbound::process_incoming(&mut st, &outer, &channel, &author_pk)
1433        };
1434        if let Some(inbound::IncomingEvent::NewMessage(msg)) = echoed {
1435            let _ = crate::db::events::save_message(channel_id, &msg).await;
1436        }
1437        Ok(message_id)
1438    }
1439
1440    /// Send a file to a Community channel as an encrypted attachment. Returns the message id.
1441    /// Mirrors the DM file pipeline (encrypt → Blossom upload → NIP-92 `imeta`) but publishes
1442    /// over the community transport.
1443    pub async fn send_community_file(&self, channel_id: &str, file_path: &str) -> Result<String> {
1444        use crate::community::{attachments, envelope, inbound, service, transport::LiveTransport};
1445        let path = std::path::Path::new(file_path);
1446        let bytes = std::fs::read(path).map_err(VectorError::Io)?;
1447        if bytes.is_empty() {
1448            return Err(VectorError::Other("Empty file".into()));
1449        }
1450        let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file").to_string();
1451        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("bin").to_lowercase();
1452
1453        // Snapshot the session BEFORE the upload: the destination below is resolved
1454        // from THIS account's DB, and the upload can outlive an account swap.
1455        let session = state::SessionGuard::capture();
1456        // Dual-stack: resolve the destination BEFORE the upload so a bad channel
1457        // fails fast (never spend an upload on an unroutable send).
1458        let v2_target = match self.v2_community_for_channel(channel_id)? {
1459            Some(id) => Some(
1460                crate::db::community::load_community_v2(&id)
1461                    .map_err(VectorError::Other)?
1462                    .ok_or_else(|| VectorError::Other("v2 community not found".into()))?,
1463            ),
1464            None => None,
1465        };
1466        let v1_target = match v2_target {
1467            Some(_) => None,
1468            None => Some(self.resolve_channel(channel_id)?),
1469        };
1470        let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1471
1472        let file_hash = crate::crypto::sha256_hex(&bytes);
1473        let mime = crate::crypto::mime_from_extension(&extension);
1474        let img_meta = crate::crypto::generate_image_metadata(&bytes);
1475
1476        // Save the plaintext locally (hash-keyed) so the sender previews it instantly.
1477        let download_dir = crate::db::get_download_dir();
1478        let _ = std::fs::create_dir_all(&download_dir);
1479        let local_name = if filename.is_empty() { format!("{}.{}", &file_hash, extension) } else { filename.clone() };
1480        let local_path = crate::crypto::resolve_unique_filename(&download_dir, &local_name);
1481        let _ = std::fs::write(&local_path, &bytes);
1482
1483        // Encrypt → upload to Blossom (signer reused for the envelope below).
1484        let params = crate::crypto::generate_encryption_params();
1485        let encrypted = crate::crypto::encrypt_data(&bytes, &params)?;
1486        let encrypted_size = encrypted.len() as u64;
1487
1488        let client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1489        let signer = client.signer().await.map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
1490        let servers = crate::blossom_servers::compute_enabled_servers();
1491        if servers.is_empty() {
1492            return Err(VectorError::Other("No Blossom servers configured".into()));
1493        }
1494        let noop_progress: crate::blossom::ProgressCallback = std::sync::Arc::new(|_, _| Ok(()));
1495        let url = crate::blossom::upload_blob_with_progress_and_failover(
1496            signer.clone(),
1497            servers,
1498            std::sync::Arc::new(encrypted),
1499            Some(mime),
1500            /* is_encrypted */ true,
1501            noop_progress,
1502            Some(3),
1503            Some(std::time::Duration::from_secs(2)),
1504            None,
1505        ).await.map_err(VectorError::Other)?;
1506
1507        let attachment = crate::types::Attachment {
1508            id: file_hash.clone(),
1509            key: params.key.clone(),
1510            nonce: params.nonce.clone(),
1511            extension: extension.clone(),
1512            name: filename.clone(),
1513            url,
1514            path: local_path.to_string_lossy().to_string(),
1515            size: encrypted_size,
1516            img_meta,
1517            downloading: false,
1518            downloaded: true,
1519            ..Default::default()
1520        };
1521        let imeta = vec![attachments::attachment_to_imeta(&attachment)];
1522
1523        // The upload straddled awaits — never publish a pre-swap destination.
1524        if !session.is_valid() {
1525            return Err(VectorError::Other("account changed during upload".into()));
1526        }
1527        // v2: the imeta rides the kind-9 rumor verbatim (NIP-92), content empty.
1528        if let Some(community) = v2_target {
1529            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
1530            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(30));
1531            return crate::community::v2::service::send_chat_message(&transport, &community, &ch, "", None, &[], imeta)
1532                .await
1533                .map_err(VectorError::Other);
1534        }
1535        let (community, channel) = v1_target.expect("v1 target resolved when no v2 community matched");
1536        let ms = std::time::SystemTime::now()
1537            .duration_since(std::time::UNIX_EPOCH)
1538            .map(|d| d.as_millis() as u64)
1539            .unwrap_or(0);
1540        let unsigned = envelope::build_inner_full(
1541            author_pk, &channel.id, channel.epoch,
1542            stored_event::event_kind::COMMUNITY_MESSAGE, "", ms, None, &[], &imeta,
1543        );
1544        let message_id = unsigned.id.ok_or_else(|| VectorError::Other("inner event has no id".into()))?.to_hex();
1545        let inner = unsigned.sign(&signer).await.map_err(|e| VectorError::Other(format!("sign: {e}")))?;
1546        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(30));
1547        let outer = service::send_signed_message(&transport, &community, &channel, &inner)
1548            .await.map_err(VectorError::Other)?;
1549        // Local echo so get_messages reflects the send.
1550        let echoed = {
1551            let mut st = state::STATE.lock().await;
1552            inbound::process_incoming(&mut st, &outer, &channel, &author_pk)
1553        };
1554        if let Some(inbound::IncomingEvent::NewMessage(m)) = echoed {
1555            let _ = crate::db::events::save_message(channel_id, &m).await;
1556        }
1557        Ok(message_id)
1558    }
1559
1560    /// Send an ephemeral typing indicator to a Community channel.
1561    pub async fn send_community_typing(&self, channel_id: &str) -> Result<()> {
1562        use crate::community::{service, transport::LiveTransport};
1563        if let Some(id) = self.v2_community_for_channel(channel_id)? {
1564            let community = crate::db::community::load_community_v2(&id)
1565                .map_err(VectorError::Other)?
1566                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1567            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
1568            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(8));
1569            return crate::community::v2::service::send_typing(&transport, &community, &ch)
1570                .await
1571                .map_err(VectorError::Other);
1572        }
1573        let (community, channel) = self.resolve_channel(channel_id)?;
1574        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(8));
1575        service::publish_typing_signal(&transport, &community, &channel)
1576            .await
1577            .map_err(VectorError::Other)
1578    }
1579
1580    /// React to a Community message. `emoji_url` carries the NIP-30 image URL for a custom
1581    /// `:shortcode:` reaction (parity with DMs).
1582    pub async fn send_community_reaction(
1583        &self,
1584        channel_id: &str,
1585        message_id: &str,
1586        emoji: &str,
1587        emoji_url: Option<&str>,
1588    ) -> Result<()> {
1589        let emoji_tags: Vec<crate::types::EmojiTag> = match emoji_url {
1590            Some(url) if emoji.starts_with(':') && emoji.ends_with(':') && emoji.len() >= 3 && !url.is_empty() => {
1591                vec![crate::types::EmojiTag { shortcode: emoji[1..emoji.len() - 1].to_string(), url: url.to_string() }]
1592            }
1593            _ => Vec::new(),
1594        };
1595        if let Some(id) = self.v2_community_for_channel(channel_id)? {
1596            let session = state::SessionGuard::capture();
1597            let community = crate::db::community::load_community_v2(&id)
1598                .map_err(VectorError::Other)?
1599                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1600            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
1601            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1602            // NIP-25 names the reacted-to author (a required `p`). STATE first, then
1603            // the persisted row (v2 history + the send echo live in the shared events
1604            // store, so this almost always resolves locally); the channel-page fetch
1605            // is the last resort for a target this device never saw.
1606            let held = {
1607                let st = state::STATE.lock().await;
1608                st.find_message(message_id)
1609                    .and_then(|(_, m)| m.npub.as_deref().and_then(|n| nostr_sdk::prelude::PublicKey::parse(n).ok()))
1610            };
1611            let held = held.or_else(|| {
1612                crate::db::events::event_author(message_id)
1613                    .ok()
1614                    .flatten()
1615                    .and_then(|n| nostr_sdk::prelude::PublicKey::parse(&n).ok())
1616            });
1617            let target_author = match held {
1618                Some(pk) => pk,
1619                None => crate::community::v2::service::fetch_channel(&transport, &community, &ch, 500)
1620                    .await
1621                    .map_err(VectorError::Other)?
1622                    .iter()
1623                    .find(|f| f.event.opened().rumor_id.to_hex() == message_id)
1624                    .map(|f| f.event.opened().author)
1625                    .ok_or_else(|| VectorError::Other("reacted-to message not found".into()))?,
1626            };
1627            // The author lookup straddled awaits against THIS account's community.
1628            if !session.is_valid() {
1629                return Err(VectorError::Other("account changed before send".into()));
1630            }
1631            let pair = emoji_tags.first().map(|t| (t.shortcode.as_str(), t.url.as_str()));
1632            // The NIP-25 `k` names the target's rumor kind. Stored rows don't keep
1633            // wire-kind fidelity yet, so a reaction to a received kind-1111 thread
1634            // reply claims `9` — Armada's fold ignores reaction `k`, and exact
1635            // threading lands with the thread-aware GUI.
1636            return crate::community::v2::service::send_reaction(
1637                &transport, &community, &ch, message_id, &target_author.to_hex(), crate::community::v2::kind::MESSAGE, emoji, pair,
1638            )
1639            .await
1640            .map(|_| ())
1641            .map_err(VectorError::Other);
1642        }
1643        self.publish_community_control(
1644            channel_id, stored_event::event_kind::COMMUNITY_REACTION, emoji, message_id, &emoji_tags,
1645        ).await
1646    }
1647
1648    /// Edit one of your own Community messages.
1649    pub async fn edit_community_message(&self, channel_id: &str, message_id: &str, new_content: &str) -> Result<()> {
1650        let emoji_tags = emoji_packs::resolve_outbound_emoji_tags(new_content);
1651        if let Some(id) = self.v2_community_for_channel(channel_id)? {
1652            let community = crate::db::community::load_community_v2(&id)
1653                .map_err(VectorError::Other)?
1654                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1655            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
1656            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1657            return crate::community::v2::service::send_edit(&transport, &community, &ch, message_id, new_content)
1658                .await
1659                .map(|_| ())
1660                .map_err(VectorError::Other);
1661        }
1662        self.publish_community_control(
1663            channel_id, stored_event::event_kind::COMMUNITY_EDIT, new_content, message_id, &emoji_tags,
1664        ).await
1665    }
1666
1667    /// Delete one of your own Community messages, resolving its channel from local
1668    /// state (the GUI path). A headless v2 consumer holds no local history — use
1669    /// [`Self::delete_community_message_in`] with the channel id instead.
1670    pub async fn delete_community_message(&self, message_id: &str) -> Result<()> {
1671        let channel_id = {
1672            let st = state::STATE.lock().await;
1673            match st.find_message(message_id) {
1674                Some((chat, _)) => chat.id.clone(),
1675                None => return Err(VectorError::Other("message not found (already deleted?)".into())),
1676            }
1677        };
1678        self.delete_community_message_in(&channel_id, message_id).await
1679    }
1680
1681    /// Delete one of your own Community messages in `channel_id`: a NIP-09 relay nuke when the
1682    /// per-message key is held (v1) or the in-plane kind-5 (v2), plus a cooperative tombstone so
1683    /// peers hide it, plus best-effort attachment cleanup.
1684    pub async fn delete_community_message_in(&self, channel_id: &str, message_id: &str) -> Result<()> {
1685        use crate::community::{service, transport::LiveTransport};
1686        let session = state::SessionGuard::capture();
1687        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1688
1689        // Attachment URLs come from local state when held (a headless v2 consumer
1690        // has none — blob cleanup is then the receiving peers' concern, not ours).
1691        let attachment_urls: Vec<String> = {
1692            let st = state::STATE.lock().await;
1693            st.find_message(message_id)
1694                .map(|(_, msg)| msg.attachments.iter().filter(|a| !a.url.is_empty()).map(|a| a.url.clone()).collect())
1695                .unwrap_or_default()
1696        };
1697
1698        if let Some(id) = self.v2_community_for_channel(channel_id)? {
1699            // v2: the cooperative in-plane kind-5 (the wrap-ciphertext scrub needs
1700            // the ephemeral wrap key, not retained in this cut).
1701            let community = crate::db::community::load_community_v2(&id)
1702                .map_err(VectorError::Other)?
1703                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1704            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(&channel_id));
1705            crate::community::v2::service::send_delete(
1706                &transport, &community, &ch, message_id, crate::community::v2::kind::MESSAGE,
1707            )
1708            .await
1709            .map_err(VectorError::Other)?;
1710        } else {
1711            // Layer 1 — relay nuke against the retained per-message key (best-effort).
1712            if crate::db::community::get_message_key(message_id).map(|k| k.is_some()).unwrap_or(false) {
1713                let _ = service::delete_message(&transport, message_id).await;
1714            }
1715            // Layer 2 — cooperative tombstone so peers hide it.
1716            self.publish_community_control(
1717                &channel_id, stored_event::event_kind::COMMUNITY_DELETE, "", message_id, &[],
1718            ).await?;
1719        }
1720        // Layer 3 — best-effort attachment blob delete.
1721        if !attachment_urls.is_empty() {
1722            if let Some(client) = state::nostr_client() {
1723                if let Ok(signer) = client.signer().await {
1724                    crate::blossom::delete_blobs_best_effort(signer, attachment_urls);
1725                }
1726            }
1727        }
1728        // Local removal — the publishes above straddled awaits; a swap must not let this
1729        // strip the message from a swapped-in account's STATE + DB (message_id is global).
1730        if !session.is_valid() {
1731            return Ok(());
1732        }
1733        let removed_chat = {
1734            let mut st = state::STATE.lock().await;
1735            st.remove_message(message_id).map(|(cid, _)| cid)
1736        };
1737        let _ = crate::db::events::delete_event(message_id).await;
1738        traits::emit_event_json("message_removed", serde_json::json!({
1739            "id": message_id, "chat_id": removed_chat.as_deref().unwrap_or(&channel_id), "reason": "deleted",
1740        }));
1741        Ok(())
1742    }
1743
1744    /// Shared community control-event publish (reaction / edit / delete tombstone): build the
1745    /// inner-typed envelope, sign, send over the community transport, then locally echo + persist + emit.
1746    async fn publish_community_control(
1747        &self,
1748        channel_id: &str,
1749        kind: u16,
1750        content: &str,
1751        target: &str,
1752        emoji_tags: &[crate::types::EmojiTag],
1753    ) -> Result<()> {
1754        use crate::community::{envelope, inbound, service, transport::LiveTransport};
1755        let (community, channel) = self.resolve_channel(channel_id)?;
1756        let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1757        let ms = std::time::SystemTime::now()
1758            .duration_since(std::time::UNIX_EPOCH)
1759            .map(|d| d.as_millis() as u64)
1760            .unwrap_or(0);
1761        let unsigned = envelope::build_inner_typed(
1762            author_pk, &channel.id, channel.epoch, kind, content, ms, Some(target), emoji_tags,
1763        );
1764        let client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1765        let signer = client.signer().await.map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
1766        let inner = unsigned.sign(&signer).await.map_err(|e| VectorError::Other(format!("sign: {e}")))?;
1767        let session = state::SessionGuard::capture();
1768        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1769        let outer = service::send_signed_message(&transport, &community, &channel, &inner)
1770            .await.map_err(VectorError::Other)?;
1771        // Local echo + persist + emit (relay echo dedups on inner id). A swap during the
1772        // publish must not echo account A's control event into account B.
1773        if !session.is_valid() {
1774            return Ok(());
1775        }
1776        let outcome = {
1777            let mut st = state::STATE.lock().await;
1778            inbound::process_incoming(&mut st, &outer, &channel, &author_pk)
1779        };
1780        if let Some(inbound::IncomingEvent::Updated { target_id, message, edit_event }) = outcome {
1781            if let Some(ev) = edit_event {
1782                let mut ev = (*ev).clone();
1783                if let Ok(cid) = crate::db::id_cache::get_chat_id_by_identifier(channel_id) { ev.chat_id = cid; }
1784                let _ = crate::db::events::save_event(&ev).await;
1785            } else {
1786                let _ = crate::db::events::save_message(channel_id, &message).await;
1787            }
1788            traits::emit_event_json("message_update", serde_json::json!({
1789                "old_id": target_id, "message": &message, "chat_id": channel_id,
1790            }));
1791        }
1792        Ok(())
1793    }
1794
1795    /// Catch a Community channel up from relays. v1: fetch + ingest the latest page of messages,
1796    /// reactions, edits, and deletes, returning how many were brand-new. v2: consensus catch-up
1797    /// only (rekeys + control refold) — chat history delivers over the live handler bridge, so the
1798    /// count is always 0. Returns `(new_message_count, warnings)`; `warnings` are NON-FATAL errors
1799    /// hit during the sync (catch-up, control fold, read-cut resume) — surfaced rather than
1800    /// swallowed so a headless caller is never blind to "the sync ran but a re-founding couldn't
1801    /// be resumed."
1802    pub async fn sync_community_channel(&self, channel_id: &str, limit: usize) -> Result<(usize, Vec<String>)> {
1803        use crate::community::{inbound, send, service, transport::LiveTransport};
1804        let my_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1805        // v2: consensus catch-up (rekeys then control refold) + chat backfill. With a
1806        // running listen() the coalescing worker owns the follow (never run inline beside
1807        // it — two concurrent follows can whole-row clobber); headless, walk it inline.
1808        // The chat page is fetched + persisted either way, so get_messages backfills.
1809        if let Some(id) = self.v2_community_for_channel(channel_id)? {
1810            let warnings = if community::v2::realtime::follow_worker_running() {
1811                community::v2::realtime::enqueue_follow(&id);
1812                Vec::new()
1813            } else {
1814                Self::v2_inline_follow(&id).await
1815            };
1816            let new = Self::v2_backfill_channel(&id, channel_id, limit).await;
1817            return Ok((new, warnings));
1818        }
1819        let (community, _) = self.resolve_channel(channel_id)?;
1820        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1821        let mut warnings: Vec<String> = Vec::new();
1822
1823        // FIRST: walk any base (server-root) rotation — a privatize / private-ban rekey advances the
1824        // epoch and re-anchors the control plane under the NEW root, so we must follow it BEFORE reading
1825        // control/messages or we'd look at stale-epoch pseudonyms and silently fall off. No-op (one cheap
1826        // probe) when there's been no rotation. Re-resolve after: the base epoch + root may have advanced.
1827        // An AUTHORIZED base rotation that excluded us (private ban / read-cut) is a removal: erase local
1828        // community data, exactly like an observed banlist/kick. This is the catch-all for a cut member who
1829        // can no longer decrypt the new control plane to read the banlist the normal way (`am_i_banned`).
1830        match service::catch_up_server_root(&transport, &community).await {
1831            Ok(c) if c.removed => {
1832                // ban-rekey exclusion is a self-removal → retain the held epoch keys for later self-scrub.
1833                let _ = crate::db::community::delete_community_retain_keys(&community.id.to_hex());
1834                return Ok((0, warnings));
1835            }
1836            Ok(_) => {}
1837            Err(e) => warnings.push(format!("base catch-up failed: {e}")),
1838        }
1839        let (community, _) = self.resolve_channel(channel_id)?;
1840
1841        // Headless clients have no realtime control-plane subscription, so fold the latest control editions
1842        // here (the desktop does the same on its own latest-page sync). Banlist FIRST: a ban that landed on
1843        // us self-removes like a kick (drop keys + local data, no rejoin). Then roles, the per-creator invite
1844        // links (Public/Private mode), and metadata (name/description/icon/channel-name) — so a rename, role,
1845        // ban, or mode change reaches this member on sync, not just in a realtime client.
1846        if let Err(e) = service::fetch_and_apply_control(&transport, &community).await {
1847            warnings.push(format!("control fold failed: {e}"));
1848        }
1849        if service::am_i_banned(&community) {
1850            // ban self-removal → retain the held epoch keys for later self-scrub.
1851            let _ = crate::db::community::delete_community_retain_keys(&community.id.to_hex());
1852            return Ok((0, warnings));
1853        }
1854        // Walk any CHANNEL rekey so we hold the current channel key before paging it, then re-resolve so the
1855        // batch below carries the fresh channel epoch/key + the freshly-folded banned set + metadata.
1856        let (community, channel) = self.resolve_channel(channel_id)?;
1857        if let Err(e) = service::catch_up_channel_rekeys(&transport, &community, &channel.id).await {
1858            warnings.push(format!("channel catch-up failed: {e}"));
1859        }
1860        // Resume any interrupted re-founding (a privatize/ban whose rotation aborted mid-way — e.g. a
1861        // transient relay miss on the re-anchor). The GUI's sync did this; the agent's path did NOT, so an
1862        // interrupted re-founding stayed `read_cut_pending` forever (channel frozen). Best-effort + surfaced.
1863        let (community, _) = self.resolve_channel(channel_id)?;
1864        if let Err(e) = service::retry_pending_read_cut(&transport, &community).await {
1865            warnings.push(format!("read-cut resume failed: {e}"));
1866        }
1867        let (community, channel) = self.resolve_channel(channel_id)?;
1868
1869        let events = send::fetch_channel_page(&transport, &community, &channel, None, None, limit.max(1))
1870            .await
1871            .map_err(VectorError::Other)?;
1872        let outcomes = {
1873            let mut st = state::STATE.lock().await;
1874            inbound::process_channel_batch(&mut st, &events, &channel, &my_pk)
1875        };
1876        let mut new = 0usize;
1877        for o in &outcomes {
1878            match o {
1879                inbound::IncomingEvent::NewMessage(m) => {
1880                    let _ = crate::db::events::save_message(channel_id, m).await;
1881                    new += 1;
1882                }
1883                inbound::IncomingEvent::Updated { message, .. } => {
1884                    let _ = crate::db::events::save_message(channel_id, message).await;
1885                }
1886                inbound::IncomingEvent::Removed { target_id } => {
1887                    let _ = crate::db::events::delete_event(target_id).await;
1888                }
1889                inbound::IncomingEvent::ReactionRemoved { reaction_id, .. } => {
1890                    // save_message is additive, so a revoked reaction's kind-7 row must be
1891                    // dropped explicitly or it resurrects on reload.
1892                    let _ = crate::db::events::delete_event(reaction_id).await;
1893                }
1894                inbound::IncomingEvent::Presence { npub, joined, event_id, created_at, invited_by, invited_label } => {
1895                    let et = if *joined {
1896                        crate::stored_event::SystemEventType::MemberJoined
1897                    } else {
1898                        crate::stored_event::SystemEventType::MemberLeft
1899                    };
1900                    // attribution persisted in the note: "invited_by[|label]".
1901                    let note = invited_by.as_ref().map(|by| match invited_label {
1902                        Some(l) if !l.is_empty() => format!("{by}|{l}"),
1903                        _ => by.clone(),
1904                    });
1905                    let _ = crate::db::events::save_system_event_at(event_id, channel_id, et, npub, note.as_deref(), *created_at, invited_by.as_deref(), invited_label.as_deref()).await;
1906                }
1907                inbound::IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, event_id, created_at } => {
1908                    // Persist only (DM-parity row) — the miniapp layer bootstraps from the DB at
1909                    // game-open. Live gossip-feed pokes are the realtime subscription's job.
1910                    community::service::persist_webxdc_signal(
1911                        channel_id, npub, topic_id, node_addr.as_deref(), event_id, *created_at,
1912                    ).await;
1913                }
1914                inbound::IncomingEvent::Kicked { community_id }
1915                | inbound::IncomingEvent::SelfLeft { community_id } => {
1916                    // self-removal (kick of me, or a leave I/another device authored): drop the
1917                    // community's local state but RETAIN the held epoch keys (later self-scrub). The core-level
1918                    // half of leaving; a client shell layers on subscription-refresh + chat-row teardown + UI.
1919                    // Stop the batch — the community is gone, so later same-batch writes would orphan rows.
1920                    let _ = crate::db::community::delete_community_retain_keys(community_id);
1921                    break;
1922                }
1923                inbound::IncomingEvent::Typing { .. } => {
1924                    // Realtime-only ephemeral signal; never fetched in a sync batch. No-op.
1925                }
1926            }
1927        }
1928        Ok((new, warnings))
1929    }
1930
1931    /// The composer's `/` picker snapshot for `chat_id`, answered INSTANTLY
1932    /// from local state: the chat's bot-flagged members (kind-0 `bot: true` —
1933    /// the SDK sets it on every bot it builds) and their last-known manifests
1934    /// from the persistent store. When the last refresh is older than a minute
1935    /// (or the bot set changed), ONE background REQ re-fetches every bot's
1936    /// manifest together (5s unification window), persists newer editions, and
1937    /// emits `chat_commands_updated` — the UI swaps the list in when it lands.
1938    /// Works for BOTH community protocols (an invocation is plain content; only
1939    /// the optional routing tag is v2-only) and DMs. The manifest REQ always
1940    /// includes the discovery indexers beside the chat's own relays, so an
1941    /// unreachable or stranger-dropping community relay can't blind the picker.
1942    pub async fn get_chat_commands(&self, chat_id: &str) -> crate::bot_interface::ChatCommandsSnapshot {
1943        use crate::bot_interface::{self, ChatCommandsSnapshot};
1944        use nostr_sdk::prelude::ToBech32;
1945
1946        let mut bots: Vec<nostr_sdk::prelude::PublicKey> = Vec::new();
1947        let mut relays: Vec<String> = Vec::new();
1948        let community_hex = crate::db::community::community_id_for_channel(chat_id).ok().flatten();
1949        if let Some(cid_hex) = community_hex {
1950            let mut members: Vec<nostr_sdk::prelude::PublicKey> = Vec::new();
1951            if let Ok(Some(community)) = Self::load_v2_if_v2(&cid_hex) {
1952                members = community::v2::service::stored_memberlist(&community).unwrap_or_default();
1953                relays = community.relays.clone();
1954            } else {
1955                let id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
1956                let Ok(Some(community)) = crate::db::community::load_community(&id) else {
1957                    return ChatCommandsSnapshot { bots: 0, commands: Vec::new(), fresh: true };
1958                };
1959                relays = community.relays.clone();
1960                for (npub, _) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
1961                    if let Ok(pk) = nostr_sdk::prelude::PublicKey::parse(&npub) {
1962                        members.push(pk);
1963                    }
1964                }
1965            }
1966            let state = crate::state::STATE.lock().await;
1967            for pk in members {
1968                let Ok(npub) = pk.to_bech32() else { continue };
1969                if state.get_profile(&npub).map(|p| p.flags.is_bot()).unwrap_or(false) {
1970                    bots.push(pk);
1971                }
1972            }
1973        } else if chat_id.starts_with("npub1") {
1974            if let Ok(pk) = nostr_sdk::prelude::PublicKey::parse(chat_id) {
1975                let is_bot = {
1976                    let state = crate::state::STATE.lock().await;
1977                    state.get_profile(chat_id).map(|p| p.flags.is_bot()).unwrap_or(false)
1978                };
1979                if is_bot {
1980                    bots.push(pk);
1981                    // The counterpart published its manifest to its own login
1982                    // relays/indexers — our connected pool is the read set.
1983                    if let Some(client) = crate::state::nostr_client() {
1984                        relays = client.relays().await.keys().map(|u| u.to_string()).collect();
1985                    }
1986                }
1987            }
1988        }
1989
1990        if bots.is_empty() {
1991            return ChatCommandsSnapshot { bots: 0, commands: Vec::new(), fresh: true };
1992        }
1993        // The chat's own relays PLUS the discovery indexers, one REQ across the
1994        // union — a room whose relays refuse kind 10304 still resolves.
1995        relays.extend(bot_interface::DISCOVERY_RELAYS.iter().map(|s| s.to_string()));
1996        relays.sort();
1997        relays.dedup();
1998        // Deterministic order: the freshness check compares the exact bot set,
1999        // and picker sections stay stable across refreshes.
2000        bots.sort_by_key(|p| p.to_hex());
2001        let bot_hexes: Vec<String> = bots.iter().map(|p| p.to_hex()).collect();
2002        let commands = bot_interface::assemble_from_store(&bot_hexes);
2003        let fresh = bot_interface::commands_fresh(chat_id, &bot_hexes);
2004        if !fresh {
2005            bot_interface::spawn_commands_refresh(chat_id.to_string(), bots.clone(), relays);
2006        }
2007        ChatCommandsSnapshot { bots: bots.len(), commands, fresh }
2008    }
2009
2010    /// Observed members of a Community (best-effort: those who've posted or announced a join,
2011    /// minus anyone who's left or is banned). v1 entries are `{npub, last_active}`; a v2 entry
2012    /// is `{npub}` (the Complete Memberlist carries no activity time). Best-effort throughout:
2013    /// a transport failure yields an empty list, never an error.
2014    pub async fn get_community_members(&self, community_id: &str) -> Vec<serde_json::Value> {
2015        use nostr_sdk::prelude::ToBech32;
2016        // v2: the Complete Memberlist from LOCAL state (persisted guestbook +
2017        // observed authors + roster grantees − banlist). The store is seeded
2018        // post-join and cursor-caught-up by the follow worker (boot/reconnect) +
2019        // live ingest; a cold store (a hold predating the store) seeds in the
2020        // background and refreshes the UI when it lands.
2021        match Self::load_v2_if_v2(community_id) {
2022            Ok(Some(community)) => {
2023                let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2024                let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap_or_default();
2025                if cursor == 0 {
2026                    if crate::community::v2::realtime::follow_worker_running() {
2027                        crate::community::v2::realtime::enqueue_follow(community.id());
2028                    } else {
2029                        let session = state::SessionGuard::capture();
2030                        let c2 = community.clone();
2031                        tokio::spawn(async move {
2032                            if !session.is_valid() {
2033                                return;
2034                            }
2035                            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(20));
2036                            if matches!(crate::community::v2::service::sync_guestbook(&transport, &c2, &session).await, Ok(fresh) if !fresh.is_empty()) {
2037                                emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
2038                            }
2039                        });
2040                    }
2041                }
2042                return crate::community::v2::service::stored_memberlist(&community)
2043                    .unwrap_or_default()
2044                    .into_iter()
2045                    .filter_map(|pk| pk.to_bech32().ok())
2046                    .map(|npub| serde_json::json!({ "npub": npub }))
2047                    .collect();
2048            }
2049            Ok(None) => {} // genuinely v1 / unknown — fall through.
2050            // Can't determine the protocol: best-effort empty, never a v1 guess.
2051            Err(_) => return Vec::new(),
2052        }
2053        crate::db::community::community_member_activity(community_id)
2054            .unwrap_or_default()
2055            .into_iter()
2056            .map(|(npub, last_active)| serde_json::json!({ "npub": npub, "last_active": last_active }))
2057            .collect()
2058    }
2059
2060    /// One synchronous v2 follow pass — rekeys first (a base adopt moves the
2061    /// control address), then a control refold on the FRESH state, the same order
2062    /// the live follow worker runs. Returns non-fatal warnings.
2063    async fn v2_inline_follow(id: &crate::community::CommunityId) -> Vec<String> {
2064        use crate::community::transport::LiveTransport;
2065        let session = state::SessionGuard::capture();
2066        // Serialize with the live follow worker: `follow_worker_running` is
2067        // check-then-act, so a worker can spawn right after a caller saw `false` —
2068        // this shared per-community lock is what actually prevents two follows of
2069        // one community interleaving their whole-row saves.
2070        let lock = crate::community::v2::realtime::follow_lock(id);
2071        let _guard = lock.lock().await;
2072        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2073        let mut warnings: Vec<String> = Vec::new();
2074        let Ok(Some(community)) = crate::db::community::load_community_v2(id) else {
2075            warnings.push("v2 community not found".to_string());
2076            return warnings;
2077        };
2078        let cid_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
2079        match crate::community::v2::service::follow_rekeys(&transport, &community, &session).await {
2080            // A tombstone surfaced during catch-up — sealed read-only; stop here.
2081            Ok(f) if f.dissolved => return warnings,
2082            Ok(f) if f.self_removed => {
2083                // An authorized rotation that excluded us IS a removal — but the
2084                // follow straddled awaits, so never delete from a swapped-in DB.
2085                if session.is_valid() {
2086                    let _ = crate::db::community::delete_community(&cid_hex);
2087                }
2088                return warnings;
2089            }
2090            Ok(_) => {}
2091            Err(e) => warnings.push(format!("v2 rekey follow failed: {e}")),
2092        }
2093        if let Ok(Some(fresh)) = crate::db::community::load_community_v2(id) {
2094            match crate::community::v2::service::follow_control(&transport, &fresh, &session).await {
2095                // A control change can reveal rekey work that predates it (a
2096                // just-announced private channel's key crate already sits on its
2097                // rekey plane), so walk the rekeys once more on the fresh state.
2098                Ok(Some(changed)) => {
2099                    if let Err(e) = crate::community::v2::service::follow_rekeys(&transport, &changed, &session).await {
2100                        warnings.push(format!("v2 rekey follow failed: {e}"));
2101                    }
2102                }
2103                Ok(None) => {}
2104                Err(e) => warnings.push(format!("v2 control follow failed: {e}")),
2105            }
2106        }
2107        warnings
2108    }
2109
2110    /// Fetch a v2 channel's recent chat history and PERSIST it into the shared events
2111    /// tables (the same store v1 uses), so `get_messages`/`get_new_messages` backfill for
2112    /// v2 exactly like v1. PAGES backwards until it reaches messages it already holds
2113    /// (bounded), so a reconnecting bot that slept through more than one page of traffic
2114    /// still catches the whole gap instead of only the newest `limit`. Reuses the v2
2115    /// inbound bridge (dedup + STATE aggregate) + the v1 save path. Returns the count of
2116    /// brand-new messages applied. Best-effort: a fetch failure is 0.
2117    async fn v2_backfill_channel(id: &crate::community::CommunityId, channel_id: &str, limit: usize) -> usize {
2118        use crate::community::v2::inbound::{apply_chat_to_state, persist_chat, ChatPersist};
2119        /// Deepest catch-up walk: pages × page-size bounds one reconnect's fetch.
2120        const MAX_BACKFILL_PAGES: usize = 8;
2121        // Guard straddles the fetch: a swap mid-fetch must not persist account A's chat
2122        // into account B's STATE/DB (the message ids are global).
2123        let session = state::SessionGuard::capture();
2124        let Some(my_pk) = state::my_public_key() else { return 0 };
2125        // CORD-02 §9: a dissolved community honors no NEW events — old history reads
2126        // through the explicit paths, but a catch-up sweep must not ingest anything
2127        // authored into the grave.
2128        if crate::db::community::get_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&id.0)).unwrap_or(false) {
2129            return 0;
2130        }
2131        let Ok(Some(community)) = crate::db::community::load_community_v2(id) else { return 0 };
2132        let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
2133        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2134        let Ok(page) = crate::community::v2::service::fetch_channel_history(
2135            &transport,
2136            &community,
2137            &ch,
2138            limit.max(50),
2139            MAX_BACKFILL_PAGES,
2140            // Keep paging while a page still contains a MESSAGE we don't hold; a page
2141            // whose messages are all known means we've reached our own history. Only
2142            // message kinds get their own rows (reactions/edits fold into their
2143            // targets), so a page with no messages is undecidable — keep paging.
2144            |page| {
2145                let mut saw_message = false;
2146                for f in page {
2147                    if matches!(&f.event, crate::community::v2::chat::ChatEvent::Message { .. }) {
2148                        saw_message = true;
2149                        if !crate::db::events::event_exists(&f.event.opened().rumor_id.to_hex()).unwrap_or(false) {
2150                            return true;
2151                        }
2152                    }
2153                }
2154                !saw_message
2155            },
2156        )
2157        .await
2158        else {
2159            return 0;
2160        };
2161        let mut new = 0usize;
2162        for f in &page {
2163            // Re-check every iteration — each persists a DB write, and a swap can land
2164            // between them.
2165            if !session.is_valid() {
2166                break;
2167            }
2168            // A backfilled WebXDC peer ad persists through the shared 30078 row
2169            // (recency-gated at read) so a reopening lobby lists peers who
2170            // advertised while this device was closed — v1 sync parity. Own
2171            // echoes drop; the ad is not a chat row.
2172            if let crate::community::v2::chat::ChatEvent::Webxdc { opened } = &f.event {
2173                if opened.author != my_pk {
2174                    if let Some((topic, addr)) = crate::webxdc::parse_peer_signal(&opened.rumor.content) {
2175                        if let Ok(npub) = ToBech32::to_bech32(&opened.author) {
2176                            crate::community::service::persist_webxdc_signal(
2177                                channel_id,
2178                                &npub,
2179                                &topic,
2180                                addr.as_deref(),
2181                                &opened.rumor_id.to_hex(),
2182                                opened.at_ms / 1000,
2183                            )
2184                            .await;
2185                        }
2186                    }
2187                }
2188                continue;
2189            }
2190            // STATE mutation under the lock; the async DB persist after it drops.
2191            let outcome = {
2192                let mut st = state::STATE.lock().await;
2193                apply_chat_to_state(&mut st, &f.event, channel_id, &my_pk)
2194            };
2195            if let Some(outcome) = outcome {
2196                if matches!(outcome, ChatPersist::New(_)) {
2197                    new += 1;
2198                }
2199                persist_chat(channel_id, &outcome).await;
2200            }
2201        }
2202        new
2203    }
2204
2205    /// The held v2 community when `community_id` names one; `Ok(None)` for v1 (or
2206    /// unknown). A DB read error PROPAGATES (fail-closed) instead of falling open
2207    /// to the v1 route on a transient failure.
2208    fn load_v2_if_v2(community_id: &str) -> Result<Option<crate::community::v2::community::CommunityV2>> {
2209        if community_id.len() != 64 {
2210            return Ok(None);
2211        }
2212        let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
2213        match crate::db::community::community_protocol(&cid).map_err(VectorError::Other)? {
2214            Some(crate::community::ConcordProtocol::V2) => crate::db::community::load_community_v2(&cid).map_err(VectorError::Other),
2215            _ => Ok(None),
2216        }
2217    }
2218
2219    // ── Community admin actions ── role-gated; vector-core re-checks authority on every action and peers
2220    // re-verify against the owner-rooted roster, so these can't forge standing. A bunker account can't ban
2221    // in a private community (the rekey needs a raw local key).
2222
2223    fn load_community_hex(community_id: &str) -> Result<crate::community::Community> {
2224        use crate::community::CommunityId;
2225        if community_id.len() != 64 {
2226            return Err(VectorError::Other("malformed community id".into()));
2227        }
2228        crate::db::community::load_community(&CommunityId(crate::simd::hex::hex_to_bytes_32(community_id)))
2229            .map_err(VectorError::Other)?
2230            .ok_or_else(|| VectorError::Other("community not found".into()))
2231    }
2232
2233    fn admin_role_id_of(community_id: &str) -> Result<String> {
2234        let roles = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
2235        roles.roles.iter()
2236            .find(|r| matches!(r.scope, crate::community::roles::RoleScope::Server)
2237                && r.permissions.contains(crate::community::roles::Permissions::ADMIN_ALL))
2238            .map(|r| r.role_id.clone())
2239            .ok_or_else(|| VectorError::Other("admin role not found (roster not synced?)".into()))
2240    }
2241
2242    /// My effective management capabilities in a community (role engine — owner is just position 0). Use to
2243    /// confirm a promotion/demotion landed. A local read: the roster is folded + persisted by the passive
2244    /// sync (v1) / control follow (v2), never fetched here.
2245    pub fn community_capabilities(&self, community_id: &str) -> Result<serde_json::Value> {
2246        use crate::community::service;
2247        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
2248            use crate::community::roles::Permissions;
2249            let me = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?.to_hex();
2250            let owner_hex = v2.owner().map_err(VectorError::Other)?.to_hex();
2251            let roster = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
2252            // A banned member holds no standing (CORD-04 §4), even if a since-skipped
2253            // roster persist still lists their grant — the banlist advances on its own gate.
2254            let banned = crate::db::community::get_community_banlist(community_id).unwrap_or_default();
2255            if banned.contains(&me) && me != owner_hex {
2256                return Ok(serde_json::json!({
2257                    "manage_metadata": false, "manage_channels": false, "create_invite": false, "kick": false,
2258                    "ban": false, "manage_messages": false, "manage_roles": false, "manage_admin_role": false,
2259                }));
2260            }
2261            let has = |p: u64| roster.is_authorized(&me, Some(&owner_hex), p);
2262            return Ok(serde_json::json!({
2263                "manage_metadata": has(Permissions::MANAGE_METADATA), "manage_channels": has(Permissions::MANAGE_CHANNELS),
2264                "create_invite": has(Permissions::CREATE_INVITE), "kick": has(Permissions::KICK), "ban": has(Permissions::BAN),
2265                "manage_messages": has(Permissions::MANAGE_MESSAGES), "manage_roles": has(Permissions::MANAGE_ROLES),
2266                // Only the owner (position 0) strictly outranks the position-1 Admin role.
2267                "manage_admin_role": me == owner_hex,
2268            }));
2269        }
2270        let community = Self::load_community_hex(community_id)?;
2271        let caps = service::caller_capabilities(&community);
2272        let manage_admin_role = Self::admin_role_id_of(community_id).ok()
2273            .map(|rid| service::caller_can_manage_role_id(&community, &rid))
2274            .unwrap_or(false);
2275        Ok(serde_json::json!({
2276            "manage_metadata": caps.manage_metadata, "manage_channels": caps.manage_channels,
2277            "create_invite": caps.create_invite, "kick": caps.kick, "ban": caps.ban,
2278            "manage_messages": caps.manage_messages, "manage_roles": caps.manage_roles,
2279            "manage_admin_role": manage_admin_role,
2280        }))
2281    }
2282
2283    /// The community's owner npub + the admin npubs (role overview). A local read,
2284    /// like [`Self::community_capabilities`].
2285    pub fn community_roles(&self, community_id: &str) -> Result<serde_json::Value> {
2286        use nostr_sdk::prelude::{PublicKey, ToBech32};
2287        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
2288            let owner = v2.owner().map_err(VectorError::Other)?;
2289            let roster = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
2290            // Exclude banned members from the admin list (a banned npub vanishes, §4).
2291            let banned = crate::db::community::get_community_banlist(community_id).unwrap_or_default();
2292            let admins: Vec<String> = roster.grants.iter()
2293                .filter(|g| roster.is_admin(&g.member) && !banned.contains(&g.member))
2294                .filter_map(|g| PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()))
2295                .collect();
2296            return Ok(serde_json::json!({ "owner": owner.to_bech32().ok(), "admins": admins }));
2297        }
2298        let community = Self::load_community_hex(community_id)?;
2299        let owner = community.owner_attestation.as_ref()
2300            .and_then(|att| crate::community::owner::verify_owner_attestation(att, &community.id.to_hex()))
2301            .and_then(|pk| ToBech32::to_bech32(&pk).ok());
2302        let roles = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
2303        let admins: Vec<String> = roles.grants.iter().filter(|g| roles.is_admin(&g.member))
2304            .filter_map(|g| PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()))
2305            .collect();
2306        Ok(serde_json::json!({ "owner": owner, "admins": admins }))
2307    }
2308
2309    /// Grant a member the @admin role. Requires MANAGE_ROLES + outranking the role's position.
2310    pub async fn grant_admin(&self, community_id: &str, npub: &str) -> Result<()> {
2311        use crate::community::{service, transport::LiveTransport};
2312        let member = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
2313        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2314        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
2315            return crate::community::v2::service::grant_admin(&transport, &v2, &member)
2316                .await
2317                .map_err(VectorError::Other);
2318        }
2319        let community = Self::load_community_hex(community_id)?;
2320        let role_id = Self::admin_role_id_of(community_id)?;
2321        service::grant_role(&transport, &community, member, &role_id).await.map_err(VectorError::Other)
2322    }
2323
2324    /// Revoke a member's @admin role.
2325    pub async fn revoke_admin(&self, community_id: &str, npub: &str) -> Result<()> {
2326        use crate::community::{service, transport::LiveTransport};
2327        let member = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
2328        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2329        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
2330            return crate::community::v2::service::revoke_admin(&transport, &v2, &member)
2331                .await
2332                .map_err(VectorError::Other);
2333        }
2334        let community = Self::load_community_hex(community_id)?;
2335        let role_id = Self::admin_role_id_of(community_id)?;
2336        service::revoke_role(&transport, &community, member, &role_id).await.map_err(VectorError::Other)
2337    }
2338
2339    /// Cooperatively kick a member — they self-remove but can rejoin. Requires KICK + outrank.
2340    pub async fn kick_member(&self, community_id: &str, npub: &str) -> Result<()> {
2341        use crate::community::{service, transport::LiveTransport};
2342        let pk = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
2343        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2344        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
2345            return crate::community::v2::service::kick_member(&transport, &v2, &pk)
2346                .await
2347                .map_err(VectorError::Other);
2348        }
2349        let community = Self::load_community_hex(community_id)?;
2350        let channel = community.channels.first().ok_or_else(|| VectorError::Other("community has no channel".into()))?;
2351        service::publish_kick(&transport, &community, channel, &pk.to_hex()).await.map(|_| ()).map_err(VectorError::Other)
2352    }
2353
2354    /// Ban (`true`) or unban (`false`) a member. Ban is terminal (no rejoin); in a private community it also
2355    /// fires the read-cut rekey (needs a local key). Requires BAN + outrank.
2356    pub async fn set_member_banned(&self, community_id: &str, npub: &str, banned: bool) -> Result<()> {
2357        use crate::community::{service, transport::LiveTransport, CommunityId};
2358        let pk = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
2359        let hex = pk.to_hex();
2360        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2361        // Recompute the full list (latest-wins): drop any existing entry, then add if banning.
2362        let mut list = crate::db::community::get_community_banlist(community_id).map_err(VectorError::Other)?;
2363        list.retain(|h| h != &hex);
2364        if banned {
2365            list.push(hex);
2366        }
2367        // Dual-stack: a v2 Ban is the CORD-04 §6 three-removal composition, in order —
2368        // the Banlist edition first (instant silence), then the Grant strip (authority
2369        // removal), then the Refounding read-cut (cryptographic severance).
2370        if community_id.len() == 64 {
2371            let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
2372            if let Some(Some(crate::community::ConcordProtocol::V2)) = crate::db::community::community_protocol(&cid).ok() {
2373                let community = crate::db::community::load_community_v2(&cid)
2374                    .map_err(VectorError::Other)?
2375                    .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
2376                crate::community::v2::service::set_banlist(&transport, &community, &list).await.map_err(VectorError::Other)?;
2377                if banned {
2378                    crate::community::v2::service::grant_roles(&transport, &community, &pk, vec![]).await.map_err(VectorError::Other)?;
2379                    crate::community::v2::service::refound_community(&transport, &community, &[pk]).await.map_err(VectorError::Other)?;
2380                }
2381                return Ok(());
2382            }
2383        }
2384        let community = Self::load_community_hex(community_id)?;
2385        service::publish_banlist(&transport, &community, &list).await.map_err(VectorError::Other)
2386    }
2387
2388    /// Owner dissolution / "Delete Community": publish the terminal GroupDissolved tombstone (and
2389    /// retire the owner's own invite links, no rekey), sealing the community permanently. Owner-only
2390    /// (re-verified cryptographically in `service::dissolve_community`); irreversible.
2391    pub async fn dissolve_community(&self, community_id: &str) -> Result<()> {
2392        use crate::community::{service, transport::LiveTransport, CommunityId};
2393        if community_id.len() != 64 {
2394            return Err(VectorError::Other("malformed community id".into()));
2395        }
2396        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
2397        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2398        // Dual-stack: a v2 community dissolves at its own `community_id`-derived
2399        // dissolved plane (CORD-02 §9), NOT v1's control-plane roster edition.
2400        if let Some(Some(crate::community::ConcordProtocol::V2)) = crate::db::community::community_protocol(&cid).ok() {
2401            let community = crate::db::community::load_community_v2(&cid)
2402                .map_err(VectorError::Other)?
2403                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
2404            return crate::community::v2::service::dissolve_community(&transport, &community)
2405                .await
2406                .map_err(VectorError::Other);
2407        }
2408        let community = Self::load_community_hex(community_id)?;
2409        service::dissolve_community(&transport, &community).await.map_err(VectorError::Other)
2410    }
2411
2412    /// Edit community metadata (name / description) as an authorized member (MANAGE_METADATA). `None` leaves
2413    /// a field unchanged; an empty description clears it.
2414    pub async fn edit_community_metadata(&self, community_id: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
2415        use crate::community::{service, transport::LiveTransport, CommunityId};
2416        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2417        // Dual-stack: a v2 metadata edit is an authorized vsk-0 control edition.
2418        // Overlay onto the FULL held document (`CommunityV2::metadata()`) — an
2419        // edition replaces the entity, so a bare name edit would otherwise wipe
2420        // the icon/banner for every member (CORD-02 §6).
2421        if community_id.len() == 64 {
2422            let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
2423            if let Some(Some(crate::community::ConcordProtocol::V2)) = crate::db::community::community_protocol(&cid).ok() {
2424                let community = crate::db::community::load_community_v2(&cid)
2425                    .map_err(VectorError::Other)?
2426                    .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
2427                let mut meta = community.metadata();
2428                if let Some(n) = name {
2429                    meta.name = n.to_string();
2430                }
2431                if let Some(d) = description {
2432                    meta.description = if d.is_empty() { None } else { Some(d.to_string()) };
2433                }
2434                return crate::community::v2::service::edit_community_metadata(&transport, &community, &meta)
2435                    .await
2436                    .map_err(VectorError::Other);
2437            }
2438        }
2439        let mut community = Self::load_community_hex(community_id)?;
2440        if let Some(n) = name { community.name = n.to_string(); }
2441        if let Some(d) = description { community.description = if d.is_empty() { None } else { Some(d.to_string()) }; }
2442        service::republish_community_metadata(&transport, &community).await.map_err(VectorError::Other)
2443    }
2444
2445    /// Create a new channel in a v2 community. A PUBLIC channel derives from the
2446    /// community_root, so peers fold it in with nothing to distribute; a PRIVATE one
2447    /// mints an independent key at channel-epoch 1 and delivers it to every current
2448    /// member over the rekey plane (CORD-03 §2 / CORD-06). Requires MANAGE_CHANNELS.
2449    /// Returns the new channel id (hex).
2450    pub async fn create_community_channel(&self, community_id: &str, name: &str, private: bool) -> Result<String> {
2451        let v2 = Self::load_v2_if_v2(community_id)?
2452            .ok_or_else(|| VectorError::Other("channel creation is available on v2 communities".into()))?;
2453        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2454        let id = if private {
2455            crate::community::v2::service::create_private_channel(&transport, &v2, name).await
2456        } else {
2457            crate::community::v2::service::create_public_channel(&transport, &v2, name).await
2458        }
2459        .map_err(VectorError::Other)?;
2460        // Subscribe the new channel's chat plane now — waiting on the round-trip of
2461        // our own vsk-2 edition would leave the creator deaf to first replies.
2462        if let Some(client) = state::nostr_client() {
2463            crate::community::v2::realtime::refresh_subscription(&client).await;
2464        }
2465        Ok(crate::simd::hex::bytes_to_hex_32(&id.0))
2466    }
2467
2468    /// Delete (tombstone) a v2 community channel. Requires MANAGE_CHANNELS (reader-gated).
2469    pub async fn delete_community_channel(&self, community_id: &str, channel_id: &str) -> Result<()> {
2470        let v2 = Self::load_v2_if_v2(community_id)?
2471            .ok_or_else(|| VectorError::Other("channel deletion is available on v2 communities".into()))?;
2472        let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
2473        let name = v2.channels.iter().find(|c| c.id.0 == ch.0).map(|c| c.name.clone()).unwrap_or_default();
2474        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2475        crate::community::v2::service::delete_channel(&transport, &v2, &ch, &name)
2476            .await
2477            .map_err(VectorError::Other)
2478    }
2479
2480    /// Leave a Community: announce a best-effort "left" presence (before dropping keys), then
2481    /// drop the held keys + local channel chats. You need a fresh invite to rejoin.
2482    pub async fn leave_community(&self, community_id: &str) -> Result<()> {
2483        use crate::community::{transport::LiveTransport, CommunityId};
2484        if community_id.len() != 64 {
2485            return Err(VectorError::Other("malformed community id".into()));
2486        }
2487        let id = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
2488        // v2: guestbook Leave + cross-device List tombstone + local delete, in the service.
2489        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
2490            let session = state::SessionGuard::capture();
2491            let channel_ids: Vec<String> =
2492                v2.channels.iter().map(|ch| crate::simd::hex::bytes_to_hex_32(&ch.id.0)).collect();
2493            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2494            crate::community::v2::service::leave_community(&transport, &v2)
2495                .await
2496                .map_err(VectorError::Other)?;
2497            if !session.is_valid() {
2498                return Err(VectorError::Other("account changed during leave".into()));
2499            }
2500            let mut st = state::STATE.lock().await;
2501            st.chats.retain(|c| !channel_ids.contains(&c.id));
2502            return Ok(());
2503        }
2504        let community = crate::db::community::load_community(&id).map_err(VectorError::Other)?;
2505        let channel_ids: Vec<String> = community
2506            .as_ref()
2507            .map(|c| c.channels.iter().map(|ch| ch.id.to_hex()).collect())
2508            .unwrap_or_default();
2509        // "Left" announcement BEFORE dropping keys (afterward we can't sign/seal into the channel).
2510        if let Some(ref c) = community {
2511            if let Some(primary) = c.channels.first() {
2512                let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2513                let _ = crate::community::service::publish_presence(&transport, c, primary, false, None).await;
2514            }
2515        }
2516        // voluntary leave is a self-removal → retain the held epoch keys for later self-scrub.
2517        crate::db::community::delete_community_retain_keys(community_id).map_err(VectorError::Other)?;
2518        {
2519            let mut st = state::STATE.lock().await;
2520            st.chats.retain(|c| !channel_ids.contains(&c.id));
2521        }
2522        Ok(())
2523    }
2524
2525    /// Resolve a channel id to its owning Community + the Channel (with its secret key).
2526    fn resolve_channel(
2527        &self,
2528        channel_id: &str,
2529    ) -> Result<(crate::community::Community, crate::community::Channel)> {
2530        use crate::community::CommunityId;
2531        let community_id = crate::db::community::community_id_for_channel(channel_id)
2532            .map_err(VectorError::Other)?
2533            .ok_or_else(|| VectorError::Other("Unknown Community channel".into()))?;
2534        if community_id.len() != 64 {
2535            return Err(VectorError::Other("malformed community id".into()));
2536        }
2537        let community = crate::db::community::load_community(&CommunityId(
2538            crate::simd::hex::hex_to_bytes_32(&community_id),
2539        ))
2540        .map_err(VectorError::Other)?
2541        .ok_or_else(|| VectorError::Other("Community not found".into()))?;
2542        let channel = community
2543            .channels
2544            .iter()
2545            .find(|c| c.id.to_hex() == channel_id)
2546            .cloned()
2547            .ok_or_else(|| VectorError::Other("Channel not found in Community".into()))?;
2548        Ok((community, channel))
2549    }
2550
2551
2552    /// Sync DM history from relays using NIP-77 negentropy set reconciliation.
2553    ///
2554    /// Reconciles local wrapper history with relay state, fetches missing events,
2555    /// and processes them through the standard prepare → commit pipeline.
2556    ///
2557    /// Returns (total_events, new_messages).
2558    ///
2559    /// ```no_run
2560    /// # async fn example() -> vector_core::Result<()> {
2561    /// let core = vector_core::VectorCore;
2562    /// // Sync last 7 days of DMs
2563    /// let (events, new) = core.sync_dms(Some(7), &vector_core::NoOpEventHandler).await?;
2564    /// println!("Processed {} events, {} new messages", events, new);
2565    /// # Ok(())
2566    /// # }
2567    /// ```
2568    pub async fn sync_dms(
2569        &self,
2570        since_days: Option<u64>,
2571        handler: &dyn InboundEventHandler,
2572    ) -> Result<(u32, u32)> {
2573        use futures_util::StreamExt;
2574        use nostr_sdk::prelude::*;
2575
2576        let client = state::nostr_client()
2577            .ok_or(VectorError::Other("Not connected".into()))?;
2578        let my_pk = state::my_public_key()
2579            .ok_or(VectorError::Other("Not logged in".into()))?;
2580
2581        // Load known wrapper IDs + timestamps for negentropy fingerprinting
2582        let all_items = db::wrappers::load_negentropy_items().unwrap_or_default();
2583
2584        // Filter items to time window (or use all for full sync)
2585        let (items, filter) = if let Some(days) = since_days {
2586            let since_ts = Timestamp::now().as_secs().saturating_sub(days * 24 * 3600);
2587            let items: Vec<(EventId, Timestamp)> = all_items.iter()
2588                .filter(|(_, ts)| ts.as_secs() >= since_ts)
2589                .cloned()
2590                .collect();
2591            let filter = Filter::new()
2592                .pubkey(my_pk)
2593                .kind(Kind::GiftWrap)
2594                .since(Timestamp::from_secs(since_ts));
2595            (items, filter)
2596        } else {
2597            let filter = Filter::new()
2598                .pubkey(my_pk)
2599                .kind(Kind::GiftWrap);
2600            (all_items, filter)
2601        };
2602
2603        log_info!("[SyncDMs] {} negentropy items, since_days={:?}", items.len(), since_days);
2604
2605        // Dry-run negentropy: exchange fingerprints to identify missing events
2606        let sync_opts = nostr_sdk::SyncOptions::new()
2607            .direction(nostr_sdk::SyncDirection::Down)
2608            .initial_timeout(std::time::Duration::from_secs(10))
2609            .dry_run();
2610
2611        // Race all relays — first to reconcile drives the fetch
2612        let relay_map = client.relays().await;
2613        let all_relays: Vec<(RelayUrl, Relay)> = relay_map.iter()
2614            .map(|(url, relay)| (url.clone(), relay.clone()))
2615            .collect();
2616        drop(relay_map);
2617
2618        let mut relay_futs = futures_util::stream::FuturesUnordered::new();
2619        for (url, relay) in &all_relays {
2620            let url = url.clone();
2621            let relay = relay.clone();
2622            let f = filter.clone();
2623            let i = items.clone();
2624            let o = sync_opts.clone();
2625            relay_futs.push(async move {
2626                let result = tokio::time::timeout(
2627                    std::time::Duration::from_secs(10),
2628                    relay.sync_with_items(f, i, &o),
2629                ).await;
2630                (url, result)
2631            });
2632        }
2633
2634        // Collect missing IDs from all relays
2635        let mut all_missing: std::collections::HashSet<EventId> = std::collections::HashSet::new();
2636        while let Some((url, result)) = relay_futs.next().await {
2637            match result {
2638                Ok(Ok(recon)) => {
2639                    let count = recon.remote.len();
2640                    all_missing.extend(recon.remote);
2641                    log_info!("[SyncDMs] {} reconciled: {} missing", url, count);
2642                }
2643                Ok(Err(e)) => log_warn!("[SyncDMs] {} failed: {}", url, e),
2644                Err(_) => log_warn!("[SyncDMs] {} timed out (10s)", url),
2645            }
2646        }
2647
2648        if all_missing.is_empty() {
2649            log_info!("[SyncDMs] No missing events");
2650            return Ok((0, 0));
2651        }
2652
2653        // Fetch missing events in batches
2654        log_info!("[SyncDMs] Fetching {} missing events", all_missing.len());
2655        let ids: Vec<EventId> = all_missing.into_iter().collect();
2656        let relay_strs: Vec<String> = client.relays().await.keys()
2657            .map(|u| u.to_string()).collect();
2658
2659        let mut total_events = 0u32;
2660        let mut new_messages = 0u32;
2661        const BATCH_SIZE: usize = 500;
2662
2663        for batch in ids.chunks(BATCH_SIZE) {
2664            let f = Filter::new().ids(batch.to_vec()).kind(Kind::GiftWrap);
2665            match client.stream_events_from(
2666                relay_strs.clone(), f,
2667                std::time::Duration::from_secs(30),
2668            ).await {
2669                Ok(stream) => {
2670                    let client_clone = client.clone();
2671                    let prepared_stream = stream
2672                        .map(move |event| {
2673                            let c = client_clone.clone();
2674                            tokio::spawn(async move {
2675                                event_handler::prepare_event(event, &c, my_pk).await
2676                            })
2677                        })
2678                        .buffer_unordered(8);
2679                    tokio::pin!(prepared_stream);
2680
2681                    while let Some(result) = prepared_stream.next().await {
2682                        total_events += 1;
2683                        if let Ok(prepared) = result {
2684                            if event_handler::commit_prepared_event(prepared, false, handler).await {
2685                                new_messages += 1;
2686                            }
2687                        }
2688                    }
2689                }
2690                Err(e) => log_warn!("[SyncDMs] Batch fetch error: {}", e),
2691            }
2692        }
2693
2694        log_info!("[SyncDMs] Complete: {} events processed, {} new messages", total_events, new_messages);
2695        Ok((total_events, new_messages))
2696    }
2697
2698    // ========================================================================
2699    // Event Subscription
2700    // ========================================================================
2701
2702    /// Subscribe to incoming DM events (NIP-17 GiftWraps).
2703    ///
2704    /// Returns the subscription ID for use in a custom notification loop.
2705    /// For a complete listen-and-process loop, use [`listen()`](Self::listen) instead.
2706    pub async fn subscribe_dms(&self) -> Result<nostr_sdk::SubscriptionId> {
2707        use nostr_sdk::prelude::*;
2708        let client = state::nostr_client()
2709            .ok_or(VectorError::Other("Not connected".into()))?;
2710        let my_pk = state::my_public_key()
2711            .ok_or(VectorError::Other("Not logged in".into()))?;
2712
2713        let filter = Filter::new()
2714            .pubkey(my_pk)
2715            .kind(Kind::GiftWrap)
2716            .limit(0);
2717
2718        let output = client.subscribe(filter, None).await
2719            .map_err(|e| VectorError::Nostr(e.to_string()))?;
2720        Ok(output.val)
2721    }
2722
2723    /// Catch up every locally-held Community: fold control / re-foundings / rekeys / banlist and
2724    /// fetch recent messages into local state for each channel. State-only (does not replay to an
2725    /// [`InboundEventHandler`]). Called at `listen()` start and periodically for outage resilience;
2726    /// also safe to call manually after a known disconnect.
2727    ///
2728    /// Catch up every locally-held Community. v1 channels are synced inline; a v2
2729    /// community is ENQUEUED for the follow worker (control/rekey re-fold + adopt),
2730    /// non-blocking. State-only (no handler replay of history). Called at `listen()`
2731    /// start and on reconnect; safe to call manually — the v2 enqueue is a no-op if
2732    /// no `listen()` worker is running.
2733    pub async fn sync_communities(&self) -> Result<()> {
2734        // Discover + rehydrate memberships from the 13302 across devices (CORD-02 §8),
2735        // bootstrapping from the client's connected relays so even a fresh device that
2736        // holds no community yet can find them. Best-effort.
2737        {
2738            use crate::community::{transport::LiveTransport, v2::service as v2};
2739            let bootstrap: Vec<String> = match crate::state::nostr_client() {
2740                Some(client) => client.relays().await.keys().map(|r| r.to_string()).collect(),
2741                None => Vec::new(),
2742            };
2743            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2744            if let Ok(joined) = v2::sync_community_list(&transport, &bootstrap).await {
2745                for c in &joined {
2746                    if community::v2::realtime::follow_worker_running() {
2747                        community::v2::realtime::enqueue_follow(c.id());
2748                    } else {
2749                        let _ = Self::v2_inline_follow(c.id()).await;
2750                    }
2751                }
2752                if !joined.is_empty() {
2753                    if let Some(client) = crate::state::nostr_client() {
2754                        community::v2::realtime::refresh_subscription(&client).await;
2755                    }
2756                }
2757            }
2758        }
2759
2760        let ids = db::community::list_community_ids().map_err(VectorError::from)?;
2761        for id in ids {
2762            if matches!(db::community::community_protocol(&id).ok().flatten(), Some(crate::community::ConcordProtocol::V2)) {
2763                // With a live listen() the coalescing worker owns the follow; headless
2764                // (no worker) it would be dropped, so walk it inline instead.
2765                if community::v2::realtime::follow_worker_running() {
2766                    community::v2::realtime::enqueue_follow(&id);
2767                } else {
2768                    let _ = Self::v2_inline_follow(&id).await;
2769                }
2770                continue;
2771            }
2772            if let Ok(Some(community)) = db::community::load_community(&id) {
2773                for ch in &community.channels {
2774                    let _ = self.sync_community_channel(&ch.id.to_hex(), 50).await;
2775                }
2776            }
2777        }
2778        Ok(())
2779    }
2780
2781
2782    /// Start listening for incoming DMs.
2783    ///
2784    /// Blocks until the client disconnects. Processes GiftWraps
2785    /// (DMs, files) → prepare_event → commit_prepared_event.
2786    ///
2787    /// ```no_run
2788    /// use vector_core::*;
2789    /// use std::sync::Arc;
2790    ///
2791    /// struct MyBot;
2792    /// impl InboundEventHandler for MyBot {
2793    ///     fn on_dm_received(&self, chat_id: &str, msg: &Message, _is_new: bool) {
2794    ///         if msg.mine { return; }
2795    ///         let to = chat_id.to_string();
2796    ///         let reply = format!("Echo: {}", msg.content);
2797    ///         tokio::spawn(async move {
2798    ///             let _ = VectorCore.send_dm(&to, &reply).await;
2799    ///         });
2800    ///     }
2801    /// }
2802    ///
2803    /// # async fn example() -> vector_core::Result<()> {
2804    /// let core = VectorCore::init(CoreConfig {
2805    ///     data_dir: "/tmp/bot-data".into(),
2806    ///     event_emitter: None,
2807    /// })?;
2808    /// core.login("nsec1...", None).await?;
2809    /// core.listen(Arc::new(MyBot)).await?;
2810    /// # Ok(())
2811    /// # }
2812    /// ```
2813    pub async fn listen(&self, handler: Arc<dyn InboundEventHandler>) -> Result<()> {
2814        use nostr_sdk::prelude::*;
2815
2816        let client = state::nostr_client()
2817            .ok_or(VectorError::Other("Not connected".into()))?;
2818        let my_pk = state::my_public_key()
2819            .ok_or(VectorError::Other("Not logged in".into()))?;
2820
2821        // Start the stream-AUTH responder BEFORE any relay interaction: a gating
2822        // relay issues its NIP-42 challenge ONCE per connection, and the DM
2823        // subscribe below consumes it via nostr-sdk's user auto-auth — if the
2824        // responder isn't already watching, that challenge is never remembered
2825        // and the stream keys registered later can NEVER authenticate (the relay
2826        // won't re-challenge an authed connection; the v2 sub dies silently).
2827        community::v2::streamauth::ensure_responder(&client);
2828
2829        // Outage resilience — catch up on connect, then re-sync periodically.
2830        //
2831        // Catch up BEFORE going realtime so a bot that was offline folds any missed re-foundings /
2832        // metadata / banlist changes (and recent messages) into local state, and subscribes at the
2833        // CURRENT epoch pseudonyms. This is state-only: historical messages are not replayed to the
2834        // handler (matches the gateway model) — query them via `get_messages`.
2835        // Spawn the single per-community follow worker for this session; the v2
2836        // follow queue (fed by dispatch, catch-up, and sync) drains through it.
2837        community::v2::realtime::spawn_follow_worker(handler.clone());
2838        let _ = self.sync_communities().await;
2839        let _ = self.sync_dms(None, &NoOpEventHandler).await;
2840
2841        // Subscribe to DMs (GiftWraps) AND Community channel events — one loop dispatches both
2842        // through the same handler, so `on_dm_received`/`on_community_message` share a sink.
2843        let dm_sub_id = self.subscribe_dms().await?;
2844        community::realtime::refresh_subscription(&client).await;
2845        community::v2::realtime::refresh_subscription(&client).await;
2846
2847        // Outage resilience via the relay Monitor — event-driven, not polling.
2848        //
2849        // (1) Reconnect-driven catch-up: a `limit(0)` realtime sub never replays what was published
2850        // while we were down, so a relay (re)connecting is exactly when we must catch up. On each
2851        // Connected transition we refold consensus + reconcile DMs (NIP-77 negentropy → only the
2852        // diff) and re-track the realtime sub at the current epochs. Idle when healthy. Stops on swap.
2853        if let Some(monitor) = client.monitor() {
2854            let mut rx = monitor.subscribe();
2855            let session = state::SessionGuard::capture();
2856            tokio::spawn(async move {
2857                // Debounce reconnect bursts: StatusChanged is per-relay, but one catch-up queries the
2858                // whole pool — so coalesce Connected transitions within a short window into one resync.
2859                let mut last_resync: Option<std::time::Instant> = None;
2860                while let Ok(notification) = rx.recv().await {
2861                    if !session.is_valid() {
2862                        return;
2863                    }
2864                    let MonitorNotification::StatusChanged { status, .. } = notification;
2865                    if status == RelayStatus::Connected {
2866                        if last_resync.is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(3)) {
2867                            continue;
2868                        }
2869                        let _ = VectorCore.sync_communities().await;
2870                        let _ = VectorCore.sync_dms(None, &NoOpEventHandler).await;
2871                        if let Some(c) = state::nostr_client() {
2872                            community::realtime::refresh_subscription(&c).await;
2873                            community::v2::realtime::refresh_subscription(&c).await;
2874                        }
2875                        last_resync = Some(std::time::Instant::now());
2876                    }
2877                }
2878            });
2879        }
2880
2881        // (2) Health probe: a relay can report Connected while silently dead. Every 60s probe each
2882        // with a tiny query + timeout; a zombie is force-reconnected (which fires the monitor above
2883        // → catch-up), and Disconnected/Terminated relays are reconnected directly.
2884        {
2885            let client_health = client.clone();
2886            let session = state::SessionGuard::capture();
2887            tokio::spawn(async move {
2888                tokio::time::sleep(std::time::Duration::from_secs(30)).await; // warm-up
2889                loop {
2890                    if !session.is_valid() {
2891                        return;
2892                    }
2893                    for (url, relay) in client_health.relays().await {
2894                        match relay.status() {
2895                            RelayStatus::Connected => {
2896                                let probe = tokio::time::timeout(
2897                                    std::time::Duration::from_secs(10),
2898                                    client_health.fetch_events_from(
2899                                        vec![url.to_string()],
2900                                        Filter::new().kind(Kind::Metadata).limit(1),
2901                                        std::time::Duration::from_secs(8),
2902                                    ),
2903                                )
2904                                .await;
2905                                if !matches!(probe, Ok(Ok(_))) {
2906                                    let _ = relay.disconnect();
2907                                    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2908                                    let _ = relay.try_connect(std::time::Duration::from_secs(10)).await;
2909                                }
2910                            }
2911                            RelayStatus::Terminated | RelayStatus::Disconnected => {
2912                                let _ = relay.try_connect(std::time::Duration::from_secs(10)).await;
2913                            }
2914                            _ => {}
2915                        }
2916                    }
2917                    tokio::time::sleep(std::time::Duration::from_secs(60)).await;
2918                }
2919            });
2920        }
2921
2922        let client_for_closure = client.clone();
2923
2924        client.handle_notifications(move |notification| {
2925            let handler = handler.clone();
2926            let c = client_for_closure.clone();
2927            let dm_sid = dm_sub_id.clone();
2928            async move {
2929                // Relay OKs feed the send pipeline: an OK that outlives the
2930                // per-attempt wait still confirms delivery, and can rescue a
2931                // message already marked Failed.
2932                if let RelayPoolNotification::Message {
2933                    message: nostr_sdk::RelayMessage::Ok { event_id, status, .. }, ..
2934                } = &notification {
2935                    sending::note_relay_ok(event_id, *status);
2936                }
2937                if let RelayPoolNotification::Event { event, subscription_id, .. } = notification {
2938                    if subscription_id == dm_sid {
2939                        // DMs, files, reactions
2940                        let prepared = event_handler::prepare_event(*event, &c, my_pk).await;
2941                        event_handler::commit_prepared_event(prepared, true, &*handler).await;
2942                    } else if community::realtime::subscription_id().await.as_ref() == Some(&subscription_id)
2943                        || community::realtime::poolwide_subscription_id().await.as_ref() == Some(&subscription_id)
2944                    {
2945                        // Community (v1) channel messages / reactions / edits / control editions.
2946                        // OR the pool-wide sub (the path that streams on Android) — else v1 events
2947                        // arriving under it match no branch and are silently dropped.
2948                        let session = state::SessionGuard::capture();
2949                        community::realtime::dispatch_event(&session, *event, handler.clone()).await;
2950                    } else if community::v2::realtime::subscription_id().await.as_ref() == Some(&subscription_id)
2951                        || community::v2::realtime::poolwide_subscription_id().await.as_ref() == Some(&subscription_id)
2952                    {
2953                        // Concord v2 plane events (authors-addressed kind-1059/21059).
2954                        let session = state::SessionGuard::capture();
2955                        community::v2::realtime::dispatch_event(&session, *event, handler.clone()).await;
2956                    }
2957                }
2958                Ok(false)
2959            }
2960        }).await.map_err(|e| VectorError::Nostr(e.to_string()))?;
2961
2962        Ok(())
2963    }
2964
2965    /// Disconnect and clean up.
2966    pub async fn logout(&self) {
2967        if let Some(client) = state::nostr_client() {
2968            let _ = client.disconnect().await;
2969        }
2970        db::close_database();
2971    }
2972
2973    /// Tear down the current session for an in-process account swap — the account-agnostic core of
2974    /// the app's `reset_session()`. Advances the session generation FIRST so any background task
2975    /// holding a `SessionGuard` short-circuits before it can touch the next account's storage; shuts
2976    /// the client down (which ends any `listen()` notification loop bound to it, so the old account's
2977    /// events can't land in the new account's DB); closes the DB pool; and clears the key vaults plus
2978    /// all in-memory per-account state. Follow with `login()` to bind the next account, then re-attach
2979    /// `listen()`. (The app's `reset_session()` additionally clears Tauri-only caches it owns.)
2980    pub async fn swap_session(&self) {
2981        // FIRST — invalidate every captured guard before any teardown begins.
2982        state::bump_session_generation();
2983
2984        // Shut the client down before anything else: this detaches relay subscriptions and ends the
2985        // prior `listen()` loop, so it stops firing the old account's events into the new session.
2986        if let Some(client) = state::take_nostr_client() {
2987            let _ = client.shutdown().await;
2988        }
2989        db::close_database();
2990
2991        // Key vaults + transient secrets.
2992        state::ENCRYPTION_KEY.clear(&[&state::MY_SECRET_KEY]);
2993        state::MY_SECRET_KEY.clear(&[&state::ENCRYPTION_KEY]);
2994        {
2995            use zeroize::Zeroize;
2996            if let Ok(mut g) = state::MNEMONIC_SEED.lock() {
2997                if let Some(s) = g.as_mut() { s.zeroize(); }
2998                *g = None;
2999            }
3000            if let Ok(mut g) = state::PENDING_NSEC.lock() {
3001                if let Some(s) = g.as_mut() { s.zeroize(); }
3002                *g = None;
3003            }
3004        }
3005
3006        // In-memory per-account state owned by vector-core's globals.
3007        {
3008            let mut st = state::STATE.lock().await;
3009            st.profiles.clear();
3010            st.chats.clear();
3011            st.db_loaded = false;
3012            st.is_syncing = false;
3013        }
3014        state::WRAPPER_ID_CACHE.lock().await.clear();
3015        state::PENDING_EVENTS.lock().await.clear();
3016        state::set_active_chat(None);
3017        crate::profile::sync::clear_profile_sync_queue();
3018        crate::inbox_relays::clear_inbox_relay_cache();
3019        // In-flight wrap confirmations carry the prior account's chat and
3020        // message ids — a late OK must not "rescue" into the new session.
3021        crate::sending::clear_wrap_confirms();
3022        crate::emoji_packs::clear_nip65_cache();
3023        // Chat/user row-id caches are PER-ACCOUNT (row ids belong to the prior account's DB). Not clearing
3024        // them here let a swapped-in account resolve a channel/npub to the WRONG (prior-account) row id →
3025        // saves FK-failed silently + reads hit the wrong row (e.g. a community member vanished post-swap).
3026        crate::db::clear_id_caches();
3027        // Community sync RAM cache (page cursors, history-start, in-flight, invite preload) is
3028        // account-scoped — drop it so the next account can't read A's cursors/warmed pages. The
3029        // generation stamp self-invalidates too, but clear explicitly for parity with the GUI swap.
3030        crate::community::cache::clear();
3031        // Community realtime route/subscription state is account-scoped (channel keys + banned sets);
3032        // drop it so a swapped-in account can't listen on the prior account's pseudonyms.
3033        crate::community::realtime::clear().await;
3034        crate::community::v2::realtime::clear().await;
3035        // Theme-pack emoji tags are account-scoped; leaving the prior account's set active would tag the
3036        // next account's outbound messages with A's theme shortcodes (leaking A's pack Blossom URLs). The
3037        // frontend re-registers the new account's theme, but only if it HAS one — clear to be safe.
3038        crate::emoji_packs::set_theme_emoji_tags(Vec::new());
3039    }
3040}
3041
3042#[cfg(test)]
3043mod facade_tests {
3044    use super::*;
3045
3046    /// SSRF regression: `download_attachment` must reject a private/link-local URL via
3047    /// `validate_url_not_private` BEFORE any network fetch (the URL is attacker-controlled).
3048    #[tokio::test]
3049    async fn download_attachment_rejects_private_url() {
3050        let att = crate::types::Attachment {
3051            url: "http://169.254.169.254/latest/meta-data/".to_string(),
3052            ..Default::default()
3053        };
3054        match VectorCore.download_attachment(&att).await {
3055            Err(VectorError::Other(msg)) => {
3056                assert!(msg.contains("Private/internal"), "expected SSRF rejection, got: {msg}")
3057            }
3058            other => panic!("expected SSRF rejection, got {other:?}"),
3059        }
3060    }
3061
3062    #[tokio::test]
3063    async fn download_attachment_rejects_empty_url() {
3064        let att = crate::types::Attachment::default();
3065        assert!(VectorCore.download_attachment(&att).await.is_err());
3066    }
3067
3068    /// The facade dual-stack dispatch: a v2 community surfaces in `list_communities`
3069    /// with `version: 2`, and `v2_community_for_channel` routes its channels to the
3070    /// v2 send path — while a v1 community is untouched (version 1).
3071    #[tokio::test]
3072    async fn list_communities_and_channel_routing_are_protocol_aware() {
3073        use crate::community::transport::memory::MemoryRelay;
3074        use nostr_sdk::prelude::Keys;
3075
3076        let _guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3077        crate::db::close_database();
3078        crate::db::clear_id_caches();
3079        let tmp = tempfile::tempdir().unwrap();
3080        // A valid bech32-charset, npub-length account dir name.
3081        let acct = {
3082            const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
3083            let mut s = String::from("npub1");
3084            for i in 0..58 {
3085                s.push(B[(i * 7 + 3) % 32] as char);
3086            }
3087            s
3088        };
3089        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
3090        crate::db::set_app_data_dir(tmp.path().to_path_buf());
3091        crate::db::set_current_account(acct.clone()).unwrap();
3092        crate::db::init_database(&acct).unwrap();
3093        let _ = crate::state::take_nostr_client();
3094        let me = Keys::generate();
3095        crate::state::MY_SECRET_KEY.store_from_keys(&me, &[]);
3096        crate::state::set_my_public_key(me.public_key());
3097
3098        // Create a v2 community directly through the v2 service (offline).
3099        let relay = MemoryRelay::new();
3100        let community = crate::community::v2::service::create_community(&relay, "V2 Guild", vec!["wss://r".into()], None)
3101            .await
3102            .unwrap();
3103        let channel_hex = crate::simd::hex::bytes_to_hex_32(&community.channels[0].id.0);
3104
3105        // The facade lists it as version 2, owned by me.
3106        let listed = VectorCore.list_communities().await;
3107        let v2 = listed.iter().find(|c| c["version"] == 2).expect("the v2 community is listed");
3108        assert_eq!(v2["name"], "V2 Guild");
3109        assert_eq!(v2["is_owner"], true);
3110        assert_eq!(v2["channels"][0]["channel_id"], channel_hex);
3111
3112        // The channel routes to the v2 send path.
3113        assert_eq!(
3114            VectorCore.v2_community_for_channel(&channel_hex).unwrap(),
3115            Some(community.identity.community_id),
3116            "a v2 channel is routed to v2"
3117        );
3118        // An unknown channel routes nowhere (would fall through to v1).
3119        assert_eq!(VectorCore.v2_community_for_channel(&"00".repeat(32)).unwrap(), None);
3120    }
3121
3122    /// The facade builds a v2 invite URL by trimming `/invite` off the v1
3123    /// constant (v2's `build_invite_url` re-appends its own `/invite/<naddr>`).
3124    /// Lock that the derived URL is v2-shaped and round-trips through the v2
3125    /// parser — a stale constant or a double-`/invite` would silently break joins.
3126    #[test]
3127    fn v2_invite_url_base_derivation_round_trips() {
3128        use crate::community::v2::derive::TOKEN_LEN;
3129        use crate::community::v2::invite::{build_invite_url, parse_invite_link};
3130        use nostr_sdk::prelude::Keys;
3131        let base = crate::community::public_invite::INVITE_URL_BASE.trim_end_matches("/invite");
3132        assert!(!base.ends_with("/invite"), "the bare domain must not carry /invite");
3133        let signer = Keys::generate();
3134        let token = [0x07u8; TOKEN_LEN];
3135        let url = build_invite_url(base, &signer.public_key(), &token, &[]).unwrap();
3136        assert!(url.contains("/invite/"), "a v2 URL carries the naddr path");
3137        assert!(!url.contains("/invite/invite/"), "no doubled /invite from the base");
3138        let parsed = parse_invite_link(&url).unwrap();
3139        assert_eq!(parsed.link_signer, signer.public_key());
3140        assert_eq!(parsed.token, token);
3141    }
3142}