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 badges;
69pub mod webxdc;
70#[cfg(feature = "tor")]
71pub mod tor;
72
73/// Build a `nostr_sdk::ClientOptions` with the embedded-Tor SOCKS proxy
74/// applied if (and only if) the `tor` feature is on AND `tor::TorService` is
75/// currently active. When Tor is off, returns the default options unchanged.
76///
77/// Note: nostr-sdk's `proxy()` lives on `Connection`, not `ClientOptions`
78/// directly — we build a `Connection` with the proxy mode and pass it via
79/// `ClientOptions::connection(...)`. The `connection()` method itself is
80/// `#[cfg(not(target_arch = "wasm32"))]`, but Vector targets are all native.
81///
82/// Callers should use this rather than `ClientOptions::new()` directly so the
83/// Tor toggle automatically covers their relay traffic.
84pub fn nostr_client_options() -> nostr_sdk::ClientOptions {
85    let opts = nostr_sdk::ClientOptions::new();
86    #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
87    {
88        match tor::transport_state() {
89            tor::TorTransportState::Active(addr) => {
90                return opts.connection(nostr_sdk::client::Connection::new().proxy(addr));
91            }
92            tor::TorTransportState::RequiredButInactive => {
93                // Tor failsafe: route to a blackhole so the relay socket can't
94                // accidentally come up direct while Tor is mid-bootstrap.
95                return opts.connection(
96                    nostr_sdk::client::Connection::new().proxy(tor::blackhole_proxy_addr()),
97                );
98            }
99            tor::TorTransportState::Disabled => {}
100        }
101    }
102    opts
103}
104
105/// Augment a `RelayOptions` with the Tor connection mode when active. Used
106/// by every site that adds a relay to the pool — default relays at boot,
107/// custom user relays, community relays, NIP-17 inbox relays — so they all
108/// come up through Tor when the toggle is on.
109///
110/// Without this, `RelayOptions::new()` (or the per-mode helper) defaults to
111/// `ConnectionMode::Direct`, and relays added at boot would stay direct even
112/// after Tor is bootstrapped — `switch_relay_transport()` covers existing
113/// relays on toggle, but not freshly-added ones.
114pub fn tor_aware_relay_options(opts: nostr_sdk::RelayOptions) -> nostr_sdk::RelayOptions {
115    #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
116    {
117        match tor::transport_state() {
118            tor::TorTransportState::Active(addr) => {
119                return opts.connection_mode(nostr_sdk::pool::ConnectionMode::proxy(addr));
120            }
121            tor::TorTransportState::RequiredButInactive => {
122                // Tor failsafe: pin to blackhole so this relay can never come
123                // up direct while Tor isn't running.
124                return opts.connection_mode(
125                    nostr_sdk::pool::ConnectionMode::proxy(tor::blackhole_proxy_addr()),
126                );
127            }
128            tor::TorTransportState::Disabled => {}
129        }
130    }
131    opts
132}
133
134/// Relay options for a Community / "external" relay: the GOSSIP flag (+ PING for a 24/7 keepalive
135/// connection). GOSSIP is read/write-capable when TARGETED — `can_read()` is `READ|GOSSIP|DISCOVERY`
136/// and `can_write()` is `WRITE|GOSSIP`, so per-relay checks pass for `fetch_events_from` /
137/// `send_event_to` / `subscribe_to`. But pool-wide ops select READ-only / WRITE-only relays, so the
138/// DM/giftwrap subscription (`subscribe(None)`) and the user's outbox (`send_event`) skip GOSSIP
139/// relays — the user's own traffic never touches relays they don't own. (A bare PING-only relay can
140/// NOT be used: `can_write()`/`can_read()` are false → the relay layer returns WriteDisabled /
141/// ReadDisabled.) An overlap relay that's ALSO a user relay keeps its existing READ+WRITE flags —
142/// `add_relay` is a no-op (`Ok(false)`) for a url already pooled, reusing the one existing connection.
143pub fn community_relay_options() -> nostr_sdk::RelayOptions {
144    use nostr_sdk::RelayServiceFlags;
145    tor_aware_relay_options(
146        nostr_sdk::RelayOptions::new().flags(RelayServiceFlags::GOSSIP | RelayServiceFlags::PING),
147    )
148}
149
150// === Event Storage ===
151pub mod stored_event;
152
153// === Rumor Processing ===
154pub mod rumor;
155
156// === Messaging ===
157pub mod sending;
158
159// === Per-DM Wallpapers ===
160pub mod wallpaper;
161
162// === Message Deletion (NIP-09 against retained gift-wraps) ===
163pub mod deletion;
164
165// === SIMD Operations ===
166pub mod simd;
167
168// === Community protocol (GROUP_PROTOCOL.md) ===
169pub mod community;
170
171// === Event Handler ===
172pub mod event_handler;
173
174// === Re-exports for convenience ===
175pub use types::{Message, Attachment, Reaction, EditEntry, ImageMetadata, SiteMetadata, LoginResult, AttachmentFile, mention, extract_mentions};
176pub use profile::{Profile, ProfileFlags, SlimProfile, Status};
177pub use chat::{Chat, ChatType, ChatMetadata, SerializableChat};
178pub use compact::{CompactMessage, CompactMessageVec, NpubInterner};
179pub use state::{
180    ChatState, NOSTR_CLIENT, MY_SECRET_KEY, MY_PUBLIC_KEY, STATE, ENCRYPTION_KEY,
181    nostr_client, my_public_key, has_active_session,
182    set_nostr_client, set_my_public_key,
183    take_nostr_client, clear_my_public_key,
184    set_pending_bunker_setup, pending_bunker_setup, clear_pending_bunker_setup,
185};
186pub use crypto::{GuardedKey, GuardedSigner};
187pub use signer::{
188    SignerKind, signer_kind, set_signer_kind, is_bunker,
189    BUNKER_SIGNER, bunker_signer, set_bunker_signer, take_bunker_signer,
190    build_bunker_signer, prewarm_bunker, drain_bunker_state,
191    parse_bunker_remote_pubkey, parse_bunker_relays,
192    BunkerConnectionState, bunker_state, set_bunker_state,
193    VectorAuthUrlHandler, attempt_bunker_login, WatchedBunkerSigner,
194    vector_metadata, build_nostrconnect_uri, build_nostrconnect_session,
195    VECTOR_APP_NAME, VECTOR_APP_URL, VECTOR_APP_ICON,
196};
197pub use error::{VectorError, Result};
198pub use traits::{EventEmitter, NoOpEmitter, set_event_emitter, emit_event};
199pub use db::{set_app_data_dir, get_app_data_dir};
200pub use sending::{SendCallback, NoOpSendCallback, SendConfig, SendResult};
201pub use deletion::{delete_own_dm, DeleteOutcome};
202pub use stored_event::{StoredEvent, StoredEventBuilder, SystemEventType};
203pub use rumor::{RumorEvent, RumorContext, ConversationType, RumorProcessingResult, process_rumor};
204pub use profile::{SyncPriority, ProfileSyncHandler, NoOpProfileSyncHandler};
205pub use event_handler::{InboundEventHandler, NoOpEventHandler, PreparedEvent, process_event};
206
207use std::path::PathBuf;
208use std::sync::Arc;
209
210// ============================================================================
211// VectorCore — High-level API
212// ============================================================================
213
214/// Configuration for initializing VectorCore.
215pub struct CoreConfig {
216    /// Path to the app data directory (e.g., ~/.local/share/io.vectorapp/data/)
217    pub data_dir: PathBuf,
218    /// Optional event emitter for UI integration
219    pub event_emitter: Option<Box<dyn EventEmitter>>,
220}
221
222/// The main entry point for Vector Core.
223///
224/// Provides a high-level API for all Vector operations. Internally uses
225/// global state (same pattern as the Tauri backend) for compatibility.
226///
227/// ```no_run
228/// use vector_core::{VectorCore, CoreConfig};
229/// use std::path::PathBuf;
230///
231/// # async fn example() -> vector_core::Result<()> {
232/// let core = VectorCore::init(CoreConfig {
233///     data_dir: PathBuf::from("/tmp/vector-data"),
234///     event_emitter: None,
235/// })?;
236///
237/// // Login with nsec
238/// let result = core.login("nsec1...", None).await?;
239/// println!("Logged in as {}", result.npub);
240/// # Ok(())
241/// # }
242/// ```
243#[derive(Clone, Copy)]
244pub struct VectorCore;
245
246impl VectorCore {
247    /// Initialize Vector Core with the given configuration.
248    pub fn init(config: CoreConfig) -> Result<Self> {
249        // Set data directory
250        db::set_app_data_dir(config.data_dir);
251
252        // Set event emitter (or no-op)
253        if let Some(emitter) = config.event_emitter {
254            traits::set_event_emitter(emitter);
255        }
256
257        // Install rustls ring provider
258        let _ = rustls::crypto::ring::default_provider().install_default();
259
260        Ok(VectorCore)
261    }
262
263    /// Get all available accounts.
264    pub fn accounts(&self) -> Result<Vec<String>> {
265        db::get_accounts().map_err(VectorError::from)
266    }
267
268    /// Login with an nsec key or mnemonic seed phrase.
269    pub async fn login(&self, key: &str, password: Option<&str>) -> Result<LoginResult> {
270        use nostr_sdk::prelude::*;
271
272        // Parse the key
273        let keys = if key.starts_with("nsec1") {
274            let secret = SecretKey::from_bech32(key)
275                .map_err(|e| VectorError::Nostr(format!("Invalid nsec: {}", e)))?;
276            Keys::new(secret)
277        } else {
278            // Treat as mnemonic (NIP-06: derive from BIP-39 seed)
279            Keys::from_mnemonic(key, None)
280                .map_err(|e| VectorError::Nostr(format!("Key derivation failed: {}", e)))?
281        };
282
283        let public_key = keys.public_key();
284        let npub = public_key.to_bech32()
285            .map_err(|e| VectorError::Nostr(format!("Failed to encode npub: {}", e)))?;
286
287        // Store in GuardedKey vault (pass other vaults to protect during decoy writes)
288        let secret_bytes = keys.secret_key().to_secret_bytes();
289        state::MY_SECRET_KEY.set(secret_bytes, &[&state::ENCRYPTION_KEY]);
290        state::set_my_public_key(public_key);
291
292        // Initialize database for this account
293        db::set_current_account(npub.clone())?;
294        db::init_database(&npub)?;
295
296        // Store nsec for encryption setup
297        {
298            let nsec = keys.secret_key().to_bech32()
299                .map_err(|e| VectorError::Nostr(format!("Failed to encode nsec: {}", e)))?;
300            *state::PENDING_NSEC.lock().unwrap() = Some(nsec.clone());
301
302            // NEVER clobber an existing encrypted key with the plaintext nsec. An account with encryption
303            // enabled keeps its key encrypted-at-rest (PIN-derived); overwriting it with the raw nsec — e.g.
304            // a no-password headless/diagnostic login (the concord CLI) — would leave the GUI deriving the
305            // right key from the correct PIN but trying to decrypt a value that's no longer ciphertext, i.e.
306            // "incorrect pin" with the real key effectively lost. MY_SECRET_KEY is already set in-memory above,
307            // so login works regardless; only persist the raw key when there's no encrypted key to protect.
308            let existing_encrypted = db::get_pkey().ok().flatten().is_some_and(|v| !v.starts_with("nsec1"));
309            if !(state::resolve_encryption_enabled_from_db() && existing_encrypted) {
310                db::set_pkey(&nsec)?;
311            }
312        }
313
314        // Use the canonical resolver so this high-level API agrees with
315        // crypto::is_encryption_enabled and the Android bg-sync probe.
316        let has_encryption = state::resolve_encryption_enabled_from_db();
317
318        if has_encryption {
319            if let Some(pwd) = password {
320                let key = crate::crypto::hash_pass(pwd).await;
321                state::ENCRYPTION_KEY.set(key, &[&state::MY_SECRET_KEY]);
322            }
323        }
324        // Seed the atomic unconditionally — `is_encryption_enabled_fast()`
325        // must agree with the DB regardless of branch.
326        state::init_encryption_enabled();
327
328        // Build Nostr client — tor-aware options so a headless consumer with
329        // the Tor pref ON proxies (or blackholes) instead of dialing direct.
330        let client = ClientBuilder::new()
331            .signer(keys)
332            .opts(nostr_client_options())
333            // Relay health monitor — powers the reconnect-driven catch-up in `listen()`.
334            .monitor(Monitor::new(1024))
335            .build();
336
337        // Add trusted relays
338        for relay in state::TRUSTED_RELAYS {
339            let opts = tor_aware_relay_options(nostr_sdk::RelayOptions::default());
340            client.pool().add_relay(*relay, opts).await.ok();
341        }
342
343        // Connect
344        client.connect().await;
345
346        let _ = { state::set_nostr_client(client); Ok::<(), ()>(()) };
347
348        Ok(LoginResult { npub, has_encryption })
349    }
350
351    /// Generate a fresh random account secret key (bech32 nsec). Lets a headless client spin up a
352    /// brand-new identity (`add_account` with no key) without depending on nostr-sdk directly.
353    pub fn generate_nsec(&self) -> Result<String> {
354        use nostr_sdk::prelude::*;
355        Keys::generate().secret_key().to_bech32()
356            .map_err(|e| VectorError::Nostr(format!("Failed to encode nsec: {}", e)))
357    }
358
359    /// Send a NIP-17 gift-wrapped text DM using the full pipeline.
360    pub async fn send_dm(&self, to_npub: &str, content: &str) -> Result<sending::SendResult> {
361        sending::send_dm(to_npub, content, None, &SendConfig::default(), Arc::new(NoOpSendCallback)).await
362            .map_err(|e| VectorError::Other(e))
363    }
364
365    /// Send a DM as a threaded reply to `replied_to` (an existing message's event id).
366    pub async fn send_dm_reply(&self, to_npub: &str, replied_to: &str, content: &str) -> Result<sending::SendResult> {
367        sending::send_dm(to_npub, content, Some(replied_to), &SendConfig::default(), Arc::new(NoOpSendCallback)).await
368            .map_err(|e| VectorError::Other(e))
369    }
370
371    /// Download a received attachment and decrypt it to plaintext bytes. Fetches the encrypted blob
372    /// from its Blossom URL (SSRF/Tor-aware client, size-capped) and AES-decrypts with the
373    /// attachment's embedded key + nonce.
374    pub async fn download_attachment(&self, attachment: &Attachment) -> Result<Vec<u8>> {
375        use futures_util::StreamExt;
376        const MAX_DOWNLOAD: usize = 256 * 1024 * 1024;
377        if attachment.url.is_empty() {
378            return Err(VectorError::Other("attachment has no URL".into()));
379        }
380        // SSRF guard: the URL is attacker-controlled (off an inbound message). build_http_client only
381        // validates redirect HOPS, not the initial request — so validate it here (matches the native
382        // download path). With Tor off this is the only egress guard.
383        crate::net::validate_url_not_private(&attachment.url)
384            .map_err(|e| VectorError::Other(e.to_string()))?;
385        let client = crate::net::build_http_client(std::time::Duration::from_secs(120)).map_err(VectorError::Other)?;
386        let resp = client.get(&attachment.url).send().await
387            .map_err(|e| VectorError::Other(format!("download: {e}")))?;
388        if !resp.status().is_success() {
389            return Err(VectorError::Other(format!("download failed: HTTP {}", resp.status())));
390        }
391        // Stream with a cap so a hostile/oversized blob can't OOM the process.
392        let mut encrypted: Vec<u8> = Vec::with_capacity(
393            resp.content_length().map(|l| (l as usize).min(MAX_DOWNLOAD)).unwrap_or(64 * 1024),
394        );
395        let mut stream = resp.bytes_stream();
396        while let Some(chunk) = stream.next().await {
397            let chunk = chunk.map_err(|e| VectorError::Other(format!("read body: {e}")))?;
398            if encrypted.len() + chunk.len() > MAX_DOWNLOAD {
399                return Err(VectorError::Other("attachment exceeds 256 MiB cap".into()));
400            }
401            encrypted.extend_from_slice(&chunk);
402        }
403        crate::crypto::decrypt_data(&encrypted, &attachment.key, &attachment.nonce).map_err(VectorError::Other)
404    }
405
406    /// Send a NIP-17 gift-wrapped file attachment DM.
407    pub async fn send_file(&self, to_npub: &str, file_path: &str) -> Result<sending::SendResult> {
408        let path = std::path::Path::new(file_path);
409        let bytes = std::fs::read(path)
410            .map_err(|e| VectorError::Io(e))?;
411        let filename = path.file_name()
412            .and_then(|n| n.to_str())
413            .unwrap_or("file");
414        let extension = path.extension()
415            .and_then(|e| e.to_str())
416            .unwrap_or("bin");
417
418        sending::send_file_dm(
419            to_npub,
420            std::sync::Arc::new(bytes),
421            filename,
422            extension,
423            None,
424            &SendConfig::default(),
425            Arc::new(NoOpSendCallback),
426        ).await.map_err(|e| VectorError::Other(e))
427    }
428
429    /// Send a NIP-25 reaction to a DM message. `emoji_url` carries the NIP-30
430    /// image URL when reacting with a custom-pack emoji (content stays
431    /// `:shortcode:`). Returns the reaction's rumor id. Local echo + persistence
432    /// are best-effort — the gift-wrap send is the source of truth.
433    pub async fn send_reaction(
434        &self,
435        to_npub: &str,
436        reference_id: &str,
437        emoji: &str,
438        emoji_url: Option<&str>,
439    ) -> Result<String> {
440        use nostr_sdk::prelude::*;
441
442        let client = state::nostr_client().ok_or(VectorError::Other("Not connected".into()))?;
443        let my_public_key = state::my_public_key().ok_or(VectorError::Other("Not logged in".into()))?;
444
445        let reference_event = EventId::from_hex(reference_id)
446            .map_err(|e| VectorError::Nostr(e.to_string()))?;
447        let receiver_pubkey = PublicKey::from_bech32(to_npub)
448            .map_err(|e| VectorError::Nostr(e.to_string()))?;
449
450        // NIP-30 custom-emoji tag — only when content is `:shortcode:` and a URL is present.
451        let custom_emoji_tag = emoji_url.and_then(|url| {
452            if !emoji.starts_with(':') || !emoji.ends_with(':') || emoji.len() < 3 || url.is_empty() {
453                return None;
454            }
455            let shortcode = &emoji[1..emoji.len() - 1];
456            if shortcode.is_empty() { return None; }
457            Some(Tag::custom(TagKind::custom("emoji"), [shortcode.to_string(), url.to_string()]))
458        });
459
460        let reaction_target = nostr_sdk::nips::nip25::ReactionTarget {
461            event_id: reference_event,
462            public_key: receiver_pubkey,
463            coordinate: None,
464            kind: Some(Kind::PrivateDirectMessage),
465            relay_hint: None,
466        };
467        let mut builder = EventBuilder::reaction(reaction_target, emoji);
468        if let Some(tag) = custom_emoji_tag {
469            builder = builder.tag(tag);
470        }
471        let rumor = builder.build(my_public_key);
472        let rumor_id = rumor.id.ok_or(VectorError::Other("Failed to get rumor ID".into()))?.to_hex();
473
474        inbox_relays::send_gift_wrap(&client, &receiver_pubkey, rumor.clone(), [])
475            .await.map_err(VectorError::Other)?;
476
477        // Self-wrap for recovery; bail on account swap before signing.
478        let self_wrap_client = client.clone();
479        let self_wrap_session = state::SessionGuard::capture();
480        tokio::spawn(async move {
481            if !self_wrap_session.is_valid() { return; }
482            let _ = self_wrap_client.gift_wrap(&my_public_key, rumor, []).await;
483        });
484
485        // Best-effort optimistic local echo + persistence.
486        let reaction = Reaction {
487            id: rumor_id.clone(),
488            reference_id: reference_id.to_string(),
489            author_id: my_public_key.to_bech32().unwrap_or_else(|_| my_public_key.to_hex()),
490            emoji: emoji.to_string(),
491            emoji_url: emoji_url.map(|s| s.to_string()),
492        };
493        let msg_for_save = {
494            let mut st = state::STATE.lock().await;
495            match st.add_reaction_to_message(reference_id, reaction) {
496                Some((cid, true)) => st.find_message(reference_id).map(|(_, m)| (cid, m)),
497                _ => None,
498            }
499        };
500        if let Some((cid, msg)) = msg_for_save {
501            let _ = db::events::save_message(&cid, &msg).await;
502            traits::emit_event_json("message_update", serde_json::json!({
503                "old_id": reference_id, "message": &msg, "chat_id": &cid
504            }));
505        }
506
507        Ok(rumor_id)
508    }
509
510    /// Send an ephemeral typing indicator to a DM recipient. Fire-and-forget
511    /// with a 30-second NIP-40 expiry so relays purge it quickly.
512    pub async fn send_typing(&self, to_npub: &str) -> Result<()> {
513        use nostr_sdk::prelude::*;
514
515        let client = state::nostr_client().ok_or(VectorError::Other("Not connected".into()))?;
516        let my_public_key = state::my_public_key().ok_or(VectorError::Other("Not logged in".into()))?;
517        let pubkey = PublicKey::from_bech32(to_npub).map_err(|e| VectorError::Nostr(e.to_string()))?;
518
519        let expiry = Timestamp::from_secs(Timestamp::now().as_secs() + 30);
520        let rumor = EventBuilder::new(Kind::ApplicationSpecificData, "typing")
521            .tag(Tag::public_key(pubkey))
522            .tag(Tag::custom(TagKind::d(), vec!["vector"]))
523            .tag(Tag::expiration(expiry))
524            .build(my_public_key);
525
526        client.gift_wrap_to(
527            state::active_trusted_relays().await,
528            &pubkey,
529            rumor,
530            [Tag::expiration(expiry)],
531        ).await.map_err(|e| VectorError::Nostr(e.to_string()))?;
532        Ok(())
533    }
534
535    /// Edit a DM you previously sent (kind-16 edit) with an optimistic local
536    /// echo. Returns the edit event id. Persistence is best-effort and only
537    /// happens when the chat already exists locally.
538    pub async fn edit_dm(&self, to_npub: &str, message_id: &str, new_content: &str) -> Result<String> {
539        use nostr_sdk::prelude::*;
540
541        let client = state::nostr_client().ok_or(VectorError::Other("Not connected".into()))?;
542        let my_public_key = state::my_public_key().ok_or(VectorError::Other("Not logged in".into()))?;
543        let my_npub = my_public_key.to_bech32().map_err(|e| VectorError::Nostr(e.to_string()))?;
544        let receiver_pubkey = PublicKey::from_bech32(to_npub).map_err(|e| VectorError::Nostr(e.to_string()))?;
545        let reference_event = EventId::from_hex(message_id).map_err(|e| VectorError::Nostr(e.to_string()))?;
546
547        // NIP-30: resolve `:shortcode:` so the edit carries emoji image tags.
548        let emoji_tags = emoji_packs::resolve_outbound_emoji_tags(new_content);
549
550        let mut builder = EventBuilder::new(
551            Kind::from_u16(stored_event::event_kind::MESSAGE_EDIT),
552            new_content,
553        ).tag(Tag::event(reference_event));
554        for et in &emoji_tags {
555            builder = builder.tag(Tag::custom(
556                TagKind::custom("emoji"),
557                [et.shortcode.clone(), et.url.clone()],
558            ));
559        }
560        let rumor = builder.build(my_public_key);
561        let edit_id = rumor.id.ok_or(VectorError::Other("Failed to get edit rumor ID".into()))?.to_hex();
562        let edit_ts_ms = rumor.created_at.as_secs() * 1000;
563
564        // Optimistic local echo + best-effort persistence.
565        let msg_for_emit = {
566            let mut st = state::STATE.lock().await;
567            st.update_message_in_chat(to_npub, message_id, |msg| {
568                msg.apply_edit(new_content.to_string(), edit_ts_ms, emoji_tags.clone());
569                msg.preview_metadata = None;
570            })
571        };
572        if let Some(msg) = msg_for_emit {
573            traits::emit_event_json("message_update", serde_json::json!({
574                "old_id": message_id, "message": &msg, "chat_id": to_npub
575            }));
576            if let Ok(db_chat_id) = db::id_cache::get_chat_id_by_identifier(to_npub) {
577                let _ = db::events::save_edit_event(
578                    &edit_id, message_id, new_content, &emoji_tags, db_chat_id, None, &my_npub,
579                ).await;
580            }
581        }
582
583        inbox_relays::send_gift_wrap(&client, &receiver_pubkey, rumor.clone(), [])
584            .await.map_err(VectorError::Other)?;
585
586        let self_wrap_client = client.clone();
587        let self_wrap_session = state::SessionGuard::capture();
588        tokio::spawn(async move {
589            if !self_wrap_session.is_valid() { return; }
590            let _ = self_wrap_client.gift_wrap(&my_public_key, rumor, []).await;
591        });
592
593        Ok(edit_id)
594    }
595
596    /// Delete a DM you sent (NIP-09 over the retained gift-wrap keys).
597    pub async fn delete_dm(&self, message_id: &str) -> Result<deletion::DeleteOutcome> {
598        use nostr_sdk::prelude::*;
599        let rumor_id = EventId::from_hex(message_id).map_err(|e| VectorError::Nostr(e.to_string()))?;
600        deletion::delete_own_dm(&rumor_id).await.map_err(VectorError::Other)
601    }
602
603    /// Get chats from the in-memory state.
604    pub async fn get_chats(&self) -> Vec<SerializableChat> {
605        let state = state::STATE.lock().await;
606        state.chats.iter()
607            .map(|c| c.to_serializable_with_last_n(1, &state.interner))
608            .collect()
609    }
610
611    /// Get messages for a chat (paginated).
612    pub async fn get_messages(&self, chat_id: &str, limit: usize, offset: usize) -> Vec<Message> {
613        let state = state::STATE.lock().await;
614        if let Some(chat) = state.get_chat(chat_id) {
615            let msgs = chat.get_all_messages(&state.interner);
616            let start = offset.min(msgs.len());
617            let end = (offset + limit).min(msgs.len());
618            msgs[start..end].to_vec()
619        } else {
620            Vec::new()
621        }
622    }
623
624    /// Get a profile by npub.
625    pub async fn get_profile(&self, npub: &str) -> Option<SlimProfile> {
626        let state = state::STATE.lock().await;
627        state.get_profile(npub)
628            .map(|p| SlimProfile::from_profile(p, &state.interner))
629    }
630
631    /// Fetch a profile's metadata and status from relays.
632    pub async fn load_profile(&self, npub: &str) -> bool {
633        profile::sync::load_profile(npub.to_string(), &NoOpProfileSyncHandler).await
634    }
635
636    /// Update the current user's profile metadata and broadcast to relays.
637    pub async fn update_profile(&self, name: &str, avatar: &str, banner: &str, about: &str) -> bool {
638        profile::sync::update_profile(
639            name.to_string(), avatar.to_string(), banner.to_string(), about.to_string(),
640            &NoOpProfileSyncHandler,
641        ).await
642    }
643
644    /// Like [`update_profile`](Self::update_profile) but marks the profile as a bot (`bot: true` in
645    /// the metadata). The SDK uses this for every bot; build human clients on `update_profile`.
646    pub async fn update_bot_profile(&self, name: &str, avatar: &str, banner: &str, about: &str) -> bool {
647        profile::sync::update_bot_profile(
648            name.to_string(), avatar.to_string(), banner.to_string(), about.to_string(),
649            &NoOpProfileSyncHandler,
650        ).await
651    }
652
653    /// Update the current user's status and broadcast to relays.
654    pub async fn update_status(&self, status: &str) -> bool {
655        profile::sync::update_status(status.to_string()).await
656    }
657
658    /// Upload an image file to Blossom **unencrypted** and return its public URL — for avatars,
659    /// banners, and other images other clients must fetch directly. (The opposite of
660    /// [`send_file`](Self::send_file)'s encrypted attachments.) Pass the URL to [`update_profile`].
661    ///
662    /// [`update_profile`]: Self::update_profile
663    pub async fn upload_public_image(&self, file_path: &str) -> Result<String> {
664        let path = std::path::Path::new(file_path);
665        let bytes = std::fs::read(path).map_err(VectorError::Io)?;
666        if bytes.is_empty() {
667            return Err(VectorError::Other("Empty image file".into()));
668        }
669        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("bin").to_lowercase();
670        let mime = crate::crypto::mime_from_extension(&extension);
671        let client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
672        let signer = client
673            .signer()
674            .await
675            .map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
676        let servers = crate::blossom_servers::compute_enabled_servers();
677        if servers.is_empty() {
678            return Err(VectorError::Other("No Blossom servers configured".into()));
679        }
680        crate::blossom::upload_blob_with_failover(signer, servers, std::sync::Arc::new(bytes), Some(mime))
681            .await
682            .map_err(VectorError::Other)
683    }
684
685    /// Block a user by npub.
686    pub async fn block_user(&self, npub: &str) -> bool {
687        profile::sync::block_user(npub.to_string(), &NoOpProfileSyncHandler).await
688    }
689
690    /// Unblock a user by npub.
691    pub async fn unblock_user(&self, npub: &str) -> bool {
692        profile::sync::unblock_user(npub.to_string(), &NoOpProfileSyncHandler).await
693    }
694
695    /// Set a nickname for a profile.
696    pub async fn set_nickname(&self, npub: &str, nickname: &str) -> bool {
697        profile::sync::set_nickname(npub.to_string(), nickname.to_string(), &NoOpProfileSyncHandler).await
698    }
699
700    /// Get all blocked profiles.
701    pub async fn get_blocked_users(&self) -> Vec<SlimProfile> {
702        profile::sync::get_blocked_users().await
703    }
704
705    /// Queue a profile for background sync.
706    pub fn queue_profile_sync(&self, npub: &str, priority: SyncPriority) {
707        profile::sync::queue_profile_sync(npub.to_string(), priority, false);
708    }
709
710    /// Get the current user's npub.
711    pub fn my_npub(&self) -> Option<String> {
712        state::my_public_key()
713            .and_then(|pk| ToBech32::to_bech32(&pk).ok())
714    }
715
716    // === Communities (headless) ===
717    // The GUI's Tauri commands carry optimistic-echo + emit machinery a headless client
718    // doesn't need; these are the lean equivalents over the same `community::service` layer,
719    // so a CLI / agent can join, read, post, and sync a Community.
720
721    /// List every Community held locally (owned or joined), each with its channels.
722    pub async fn list_communities(&self) -> Vec<serde_json::Value> {
723        let ids = crate::db::community::list_community_ids().unwrap_or_default();
724        let mut out = Vec::new();
725        for id in ids {
726            if let Ok(Some(c)) = crate::db::community::load_community(&id) {
727                out.push(serde_json::json!({
728                    "community_id": c.id.to_hex(),
729                    "name": c.name,
730                    "description": c.description,
731                    "is_owner": crate::community::service::is_proven_owner(&c),
732                    "channels": c.channels.iter()
733                        .map(|ch| serde_json::json!({ "channel_id": ch.id.to_hex(), "name": ch.name }))
734                        .collect::<Vec<_>>(),
735                }));
736            }
737        }
738        out
739    }
740
741    /// Join a Community from a public invite URL (`vectorapp.io/invite#...`). Fetches the
742    /// token-encrypted bundle, persists the member-view Community, and registers its channels
743    /// as chats. Returns a JSON summary.
744    pub async fn join_community(&self, invite_url: &str) -> Result<serde_json::Value> {
745        use crate::community::{public_invite, service, transport::LiveTransport};
746        let (relays, token) = public_invite::parse_invite_url(invite_url)
747            .map_err(|e| VectorError::Other(e.to_string()))?;
748        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
749        let bundle = service::fetch_public_invite(&transport, &relays, &token)
750            .await
751            .map_err(VectorError::Other)?;
752        let now = std::time::SystemTime::now()
753            .duration_since(std::time::UNIX_EPOCH)
754            .map(|d| d.as_secs())
755            .unwrap_or(0);
756        let community = service::accept_public_invite(&bundle, now).map_err(VectorError::Other)?;
757        // Attribute our join presence to the link we used (creator + label) so the owner's per-link
758        // counter ticks. Mirrors the desktop public-join path.
759        let attribution = bundle.creator_npub.clone().map(|by| (by, bundle.label.clone()));
760        self.finalize_member_join(community, &transport, attribution).await
761    }
762
763    /// List the parked private invites (giftwrapped) awaiting acceptance. Each entry is the
764    /// community id, its name (from the stored bundle), and the inviter's npub.
765    pub fn list_pending_invites(&self) -> Result<Vec<serde_json::Value>> {
766        let rows = crate::db::community::list_pending_invites().map_err(VectorError::Other)?;
767        Ok(rows.iter().map(|p| {
768            let name = crate::community::invite::CommunityInvite::from_json(&p.bundle_json)
769                .ok().map(|i| i.name).unwrap_or_default();
770            serde_json::json!({
771                "community_id": p.community_id,
772                "name": name,
773                "inviter_npub": p.inviter_npub,
774            })
775        }).collect())
776    }
777
778    /// Accept a PARKED private invite by community id: rebuild the member-view Community from the stored
779    /// bundle, finalize the join exactly like a public link, then drop the pending row. Mirrors the
780    /// desktop's consent-then-join for an invite delivered over a gift wrap.
781    pub async fn accept_pending_invite(&self, community_id: &str) -> Result<serde_json::Value> {
782        use crate::community::{invite::{CommunityInvite, accept_invite}, transport::LiveTransport};
783        let bundle_json = crate::db::community::get_pending_invite(community_id)
784            .map_err(VectorError::Other)?
785            .ok_or_else(|| VectorError::Other(format!("no pending invite for {community_id}")))?;
786        let invite = CommunityInvite::from_json(&bundle_json).map_err(VectorError::Other)?;
787        let community = accept_invite(&invite).map_err(VectorError::Other)?;
788        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
789        // Private invites carry no public-link label; the inviter attribution metric is link-only.
790        let summary = self.finalize_member_join(community, &transport, None).await?;
791        let _ = crate::db::community::delete_pending_invite(community_id);
792        Ok(summary)
793    }
794
795    /// Shared finalization for joining a Community as a member — public link OR accepted private invite.
796    /// Walks any base rekey, folds the LATEST control plane (so the joiner sees current metadata, not
797    /// the bundle's genesis snapshot), refuses if banned, registers the channels as chats, and announces
798    /// presence. Returns the JSON summary.
799    pub(crate) async fn finalize_member_join<T: crate::community::transport::Transport + ?Sized>(
800        &self,
801        community: crate::community::Community,
802        transport: &T,
803        attribution: Option<(String, Option<String>)>,
804    ) -> Result<serde_json::Value> {
805        use crate::community::service;
806        // Persist the member-view row up front: the catch-up, the control fold, and chat registration all
807        // read it back from the DB. A private bundle (unlike a public one with a preview) arrives with no
808        // display metadata, so nothing else would have saved it. UPSERT — re-saving a public join is a no-op.
809        crate::db::community::save_community(&community).map_err(VectorError::Other)?;
810        // The bundle's root can predate a base rotation, so walk any rekey first (no-op if none) — then
811        // re-load so the control fold + registration happen at the CURRENT epoch.
812        if let Ok(c) = service::catch_up_server_root(transport, &community).await {
813            if c.removed {
814                let _ = crate::db::community::delete_community(&community.id.to_hex());
815                return Err(VectorError::Other("you have been removed from this community".into()));
816            }
817        }
818        let community = crate::db::community::load_community(&community.id)
819            .map_err(VectorError::Other)?
820            .unwrap_or(community);
821        // Fold the LATEST control plane before we register anything — the joiner should see the current
822        // name/description/roster/mode immediately, not a stale snapshot. Banlist first: an honest client
823        // REFUSES to join if this npub is banned (and the just-saved community is torn back down).
824        let _ = service::fetch_and_apply_control(transport, &community).await;
825        if service::am_i_banned(&community) {
826            let _ = crate::db::community::delete_community(&community.id.to_hex());
827            return Err(VectorError::Other("you are banned from this community".into()));
828        }
829        // Re-load so the chat we register + the summary we return carry the freshly-folded latest metadata.
830        let community = crate::db::community::load_community(&community.id)
831            .map_err(VectorError::Other)?
832            .unwrap_or(community);
833        let owner_npub = community
834            .owner_attestation
835            .as_ref()
836            .and_then(|att| crate::community::owner::verify_owner_attestation(att, &community.id.to_hex()))
837            .and_then(|pk| ToBech32::to_bech32(&pk).ok());
838        {
839            let created_at_ms = crate::db::community::community_created_at_ms(&community.id);
840            let mut st = state::STATE.lock().await;
841            for ch in &community.channels {
842                st.upsert_community_chat(
843                    &ch.id.to_hex(),
844                    &community.name,
845                    community.description.as_deref().unwrap_or(""),
846                    &community.id.to_hex(),
847                    crate::community::service::is_proven_owner(&community),
848                    community.icon.is_some(),
849                    owner_npub.as_deref(),
850                    created_at_ms,
851                    community.dissolved,
852                );
853            }
854        }
855        // Best-effort join announcement (kind 3306) into the primary channel so honest peers
856        // see us in their member list even before we post. Failure must not fail the join.
857        if let Some(primary) = community.channels.first() {
858            let _ = service::publish_presence(transport, &community, primary, true, attribution).await;
859        }
860        Ok(serde_json::json!({
861            "community_id": community.id.to_hex(),
862            "name": community.name,
863            "channels": community.channels.iter()
864                .map(|c| serde_json::json!({ "channel_id": c.id.to_hex(), "name": c.name }))
865                .collect::<Vec<_>>(),
866        }))
867    }
868
869    /// Create a Community (single "general" channel) on the default trusted relays. Signs the
870    /// owner attestation with this identity (so the creator is the proven owner), registers the
871    /// channel as a chat, and returns a JSON summary.
872    pub async fn create_community(&self, name: &str) -> Result<serde_json::Value> {
873        use crate::community::{service, transport::LiveTransport};
874        let relays: Vec<String> = crate::state::active_trusted_relays()
875            .await
876            .iter()
877            .map(|s| s.to_string())
878            .collect();
879        if relays.is_empty() {
880            return Err(VectorError::Other("no relays available to host the Community".into()));
881        }
882        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
883        let community = service::create_community(&transport, name, "general", relays)
884            .await
885            .map_err(VectorError::Other)?;
886        let owner_npub = community
887            .owner_attestation
888            .as_ref()
889            .and_then(|att| crate::community::owner::verify_owner_attestation(att, &community.id.to_hex()))
890            .and_then(|pk| ToBech32::to_bech32(&pk).ok());
891        {
892            let created_at_ms = crate::db::community::community_created_at_ms(&community.id);
893            let mut st = state::STATE.lock().await;
894            for ch in &community.channels {
895                st.upsert_community_chat(
896                    &ch.id.to_hex(),
897                    &community.name,
898                    community.description.as_deref().unwrap_or(""),
899                    &community.id.to_hex(),
900                    crate::community::service::is_proven_owner(&community),
901                    community.icon.is_some(),
902                    owner_npub.as_deref(),
903                    created_at_ms,
904                    community.dissolved,
905                );
906            }
907        }
908        Ok(serde_json::json!({
909            "community_id": community.id.to_hex(),
910            "name": community.name,
911            "channels": community.channels.iter()
912                .map(|c| serde_json::json!({ "channel_id": c.id.to_hex(), "name": c.name }))
913                .collect::<Vec<_>>(),
914        }))
915    }
916
917    /// Mint a public invite link for a Community this identity owns. Returns the shareable URL.
918    pub async fn create_public_invite(&self, community_id: &str) -> Result<String> {
919        use crate::community::{service, transport::LiveTransport, CommunityId};
920        if community_id.len() != 64 {
921            return Err(VectorError::Other("malformed community id".into()));
922        }
923        let community = crate::db::community::load_community(&CommunityId(
924            crate::simd::hex::hex_to_bytes_32(community_id),
925        ))
926        .map_err(VectorError::Other)?
927        .ok_or_else(|| VectorError::Other("community not found".into()))?;
928        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
929        let (_token, url) = service::create_public_invite(&transport, &community, None, None)
930            .await
931            .map_err(VectorError::Other)?;
932        Ok(url)
933    }
934
935    /// Send a PRIVATE invite: gift-wrap this Community's invite bundle directly to an npub over a NIP-17
936    /// DM (the same transport as a regular DM). The invitee parks it pending consent (accept_pending_invite).
937    /// Requires CREATE_INVITE; a banned npub can't be re-invited. Returns the wrap's event id + relays.
938    pub async fn invite_to_community(&self, community_id: &str, invitee_npub: &str) -> Result<serde_json::Value> {
939        use crate::community::{service, CommunityId};
940        use crate::sending::{send_rumor_dm, NoOpSendCallback, SendCallback, SendConfig};
941
942        let session = crate::state::SessionGuard::capture();
943        let my_pk = crate::state::my_public_key()
944            .ok_or_else(|| VectorError::Other("Public key not set".into()))?;
945
946        if community_id.len() != 64 {
947            return Err(VectorError::Other("malformed community id".into()));
948        }
949        let community = crate::db::community::load_community(&CommunityId(
950            crate::simd::hex::hex_to_bytes_32(community_id),
951        ))
952        .map_err(VectorError::Other)?
953        .ok_or_else(|| VectorError::Other("community not found".into()))?;
954
955        if !service::caller_has_permission(&community, crate::community::roles::Permissions::CREATE_INVITE) {
956            return Err(VectorError::Other("You need the create-invite permission to invite someone".into()));
957        }
958        let invitee_hex = nostr_sdk::PublicKey::parse(invitee_npub)
959            .map_err(|_| VectorError::Other("invalid npub".into()))?
960            .to_hex();
961        if crate::db::community::get_community_banlist(community_id)
962            .map_err(VectorError::Other)?
963            .iter()
964            .any(|b| b == &invitee_hex)
965        {
966            return Err(VectorError::Other("That member is banned from this community and can't be invited".into()));
967        }
968
969        // The bundle is built from purely local state; bail if the account swapped before the gift-wrap.
970        if !session.is_valid() {
971            return Err(VectorError::Other("account changed during invite".into()));
972        }
973
974        let rumor = crate::community::invite::build_invite_rumor(&community, my_pk).map_err(VectorError::Other)?;
975        let pending_id = format!("community-invite-{}", community_id);
976        // self_send=false: the owner already holds the Community; the inbound guard would drop the echo.
977        let config = SendConfig { self_send: false, ..SendConfig::gui() };
978        let callback: Arc<dyn SendCallback> = Arc::new(NoOpSendCallback);
979
980        let result = send_rumor_dm(invitee_npub, &pending_id, rumor, &config, callback)
981            .await
982            .map_err(VectorError::Other)?;
983
984        Ok(serde_json::json!({
985            "community_id": community_id,
986            "invitee": invitee_npub,
987            "wrap_event_id": result.event_id,
988        }))
989    }
990
991    /// The public invite links this account holds for a Community (to list + revoke). Each carries the
992    /// hex `token` (the link secret) needed by [`Self::revoke_public_invite`].
993    pub fn list_public_invites(&self, community_id: &str) -> Result<Vec<crate::db::community::PublicInviteRecord>> {
994        crate::db::community::list_public_invites(community_id).map_err(VectorError::Other)
995    }
996
997    /// Revoke a public invite link by its hex token. Retiring the LAST active link flips the Community to
998    /// Private, which re-founds (rotates the base key + every channel key) to cut link-joined lurkers.
999    /// Idempotent: a token this account doesn't hold is a no-op. Needs a local key when the revoke triggers
1000    /// the privatize rekey (a bunker account can't rotate).
1001    pub async fn revoke_public_invite(&self, community_id: &str, token: &str) -> Result<()> {
1002        use crate::community::{service, transport::LiveTransport, CommunityId};
1003        if community_id.len() != 64 {
1004            return Err(VectorError::Other("malformed community id".into()));
1005        }
1006        let token_bytes = crate::simd::hex::hex_to_bytes_32(token);
1007        let community = crate::db::community::load_community(&CommunityId(
1008            crate::simd::hex::hex_to_bytes_32(community_id),
1009        ))
1010        .map_err(VectorError::Other)?
1011        .ok_or_else(|| VectorError::Other("community not found".into()))?;
1012        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(20));
1013        service::revoke_public_invite(&transport, &community, &token_bytes)
1014            .await
1015            .map_err(VectorError::Other)
1016    }
1017
1018    /// Post a text message to a Community channel. Returns the message id (the inner id).
1019    pub async fn send_community_message(
1020        &self,
1021        channel_id: &str,
1022        content: &str,
1023        replied_to: Option<&str>,
1024    ) -> Result<String> {
1025        use crate::community::{envelope, inbound, service, transport::LiveTransport};
1026        let (community, channel) = self.resolve_channel(channel_id)?;
1027        let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1028        let reply = replied_to.filter(|r| !r.is_empty());
1029        let ms = std::time::SystemTime::now()
1030            .duration_since(std::time::UNIX_EPOCH)
1031            .map(|d| d.as_millis() as u64)
1032            .unwrap_or(0);
1033        let unsigned = envelope::build_inner_typed(
1034            author_pk,
1035            &channel.id,
1036            channel.epoch,
1037            crate::stored_event::event_kind::COMMUNITY_MESSAGE,
1038            content,
1039            ms,
1040            reply,
1041            &[],
1042        );
1043        let message_id = unsigned.id.ok_or_else(|| VectorError::Other("inner event has no id".into()))?.to_hex();
1044        let client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1045        let signer = client.signer().await.map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
1046        let inner = unsigned.sign(&signer).await.map_err(|e| VectorError::Other(format!("sign: {e}")))?;
1047        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1048        let outer = service::send_signed_message(&transport, &community, &channel, &inner)
1049            .await
1050            .map_err(VectorError::Other)?;
1051        // Local echo so get_messages reflects the send (the relay echo dedups on inner id).
1052        let echoed = {
1053            let mut st = state::STATE.lock().await;
1054            inbound::process_incoming(&mut st, &outer, &channel, &author_pk)
1055        };
1056        if let Some(inbound::IncomingEvent::NewMessage(msg)) = echoed {
1057            let _ = crate::db::events::save_message(channel_id, &msg).await;
1058        }
1059        Ok(message_id)
1060    }
1061
1062    /// Send a file to a Community channel as an encrypted attachment. Returns the message id.
1063    /// Mirrors the DM file pipeline (encrypt → Blossom upload → NIP-92 `imeta`) but publishes
1064    /// over the community transport.
1065    pub async fn send_community_file(&self, channel_id: &str, file_path: &str) -> Result<String> {
1066        use crate::community::{attachments, envelope, inbound, service, transport::LiveTransport};
1067        let path = std::path::Path::new(file_path);
1068        let bytes = std::fs::read(path).map_err(VectorError::Io)?;
1069        if bytes.is_empty() {
1070            return Err(VectorError::Other("Empty file".into()));
1071        }
1072        let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file").to_string();
1073        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("bin").to_lowercase();
1074
1075        let (community, channel) = self.resolve_channel(channel_id)?;
1076        let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1077
1078        let file_hash = crate::crypto::sha256_hex(&bytes);
1079        let mime = crate::crypto::mime_from_extension(&extension);
1080        let img_meta = crate::crypto::generate_image_metadata(&bytes);
1081
1082        // Save the plaintext locally (hash-keyed) so the sender previews it instantly.
1083        let download_dir = crate::db::get_download_dir();
1084        let _ = std::fs::create_dir_all(&download_dir);
1085        let local_name = if filename.is_empty() { format!("{}.{}", &file_hash, extension) } else { filename.clone() };
1086        let local_path = crate::crypto::resolve_unique_filename(&download_dir, &local_name);
1087        let _ = std::fs::write(&local_path, &bytes);
1088
1089        // Encrypt → upload to Blossom (signer reused for the envelope below).
1090        let params = crate::crypto::generate_encryption_params();
1091        let encrypted = crate::crypto::encrypt_data(&bytes, &params)?;
1092        let encrypted_size = encrypted.len() as u64;
1093
1094        let client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1095        let signer = client.signer().await.map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
1096        let servers = crate::blossom_servers::compute_enabled_servers();
1097        if servers.is_empty() {
1098            return Err(VectorError::Other("No Blossom servers configured".into()));
1099        }
1100        let noop_progress: crate::blossom::ProgressCallback = std::sync::Arc::new(|_, _| Ok(()));
1101        let url = crate::blossom::upload_blob_with_progress_and_failover(
1102            signer.clone(),
1103            servers,
1104            std::sync::Arc::new(encrypted),
1105            Some(mime),
1106            /* is_encrypted */ true,
1107            noop_progress,
1108            Some(3),
1109            Some(std::time::Duration::from_secs(2)),
1110            None,
1111        ).await.map_err(VectorError::Other)?;
1112
1113        let attachment = crate::types::Attachment {
1114            id: file_hash.clone(),
1115            key: params.key.clone(),
1116            nonce: params.nonce.clone(),
1117            extension: extension.clone(),
1118            name: filename.clone(),
1119            url,
1120            path: local_path.to_string_lossy().to_string(),
1121            size: encrypted_size,
1122            img_meta,
1123            downloading: false,
1124            downloaded: true,
1125            ..Default::default()
1126        };
1127        let imeta = vec![attachments::attachment_to_imeta(&attachment)];
1128        let ms = std::time::SystemTime::now()
1129            .duration_since(std::time::UNIX_EPOCH)
1130            .map(|d| d.as_millis() as u64)
1131            .unwrap_or(0);
1132        let unsigned = envelope::build_inner_full(
1133            author_pk, &channel.id, channel.epoch,
1134            stored_event::event_kind::COMMUNITY_MESSAGE, "", ms, None, &[], &imeta,
1135        );
1136        let message_id = unsigned.id.ok_or_else(|| VectorError::Other("inner event has no id".into()))?.to_hex();
1137        let inner = unsigned.sign(&signer).await.map_err(|e| VectorError::Other(format!("sign: {e}")))?;
1138        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(30));
1139        let outer = service::send_signed_message(&transport, &community, &channel, &inner)
1140            .await.map_err(VectorError::Other)?;
1141        // Local echo so get_messages reflects the send.
1142        let echoed = {
1143            let mut st = state::STATE.lock().await;
1144            inbound::process_incoming(&mut st, &outer, &channel, &author_pk)
1145        };
1146        if let Some(inbound::IncomingEvent::NewMessage(m)) = echoed {
1147            let _ = crate::db::events::save_message(channel_id, &m).await;
1148        }
1149        Ok(message_id)
1150    }
1151
1152    /// Send an ephemeral typing indicator to a Community channel.
1153    pub async fn send_community_typing(&self, channel_id: &str) -> Result<()> {
1154        use crate::community::{service, transport::LiveTransport};
1155        let (community, channel) = self.resolve_channel(channel_id)?;
1156        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(8));
1157        service::publish_typing_signal(&transport, &community, &channel)
1158            .await
1159            .map_err(VectorError::Other)
1160    }
1161
1162    /// React to a Community message. `emoji_url` carries the NIP-30 image URL for a custom
1163    /// `:shortcode:` reaction (parity with DMs).
1164    pub async fn send_community_reaction(
1165        &self,
1166        channel_id: &str,
1167        message_id: &str,
1168        emoji: &str,
1169        emoji_url: Option<&str>,
1170    ) -> Result<()> {
1171        let emoji_tags: Vec<crate::types::EmojiTag> = match emoji_url {
1172            Some(url) if emoji.starts_with(':') && emoji.ends_with(':') && emoji.len() >= 3 && !url.is_empty() => {
1173                vec![crate::types::EmojiTag { shortcode: emoji[1..emoji.len() - 1].to_string(), url: url.to_string() }]
1174            }
1175            _ => Vec::new(),
1176        };
1177        self.publish_community_control(
1178            channel_id, stored_event::event_kind::COMMUNITY_REACTION, emoji, message_id, &emoji_tags,
1179        ).await
1180    }
1181
1182    /// Edit one of your own Community messages.
1183    pub async fn edit_community_message(&self, channel_id: &str, message_id: &str, new_content: &str) -> Result<()> {
1184        let emoji_tags = emoji_packs::resolve_outbound_emoji_tags(new_content);
1185        self.publish_community_control(
1186            channel_id, stored_event::event_kind::COMMUNITY_EDIT, new_content, message_id, &emoji_tags,
1187        ).await
1188    }
1189
1190    /// Delete one of your own Community messages: a NIP-09 relay nuke when the per-message key is
1191    /// held, plus a cooperative tombstone so peers hide it, plus best-effort attachment cleanup.
1192    pub async fn delete_community_message(&self, message_id: &str) -> Result<()> {
1193        use crate::community::{service, transport::LiveTransport};
1194        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1195
1196        let (channel_id, attachment_urls) = {
1197            let st = state::STATE.lock().await;
1198            match st.find_message(message_id) {
1199                Some((chat, msg)) => (
1200                    chat.id.clone(),
1201                    msg.attachments.iter().filter(|a| !a.url.is_empty()).map(|a| a.url.clone()).collect::<Vec<_>>(),
1202                ),
1203                None => return Err(VectorError::Other("message not found (already deleted?)".into())),
1204            }
1205        };
1206
1207        // Layer 1 — relay nuke against the retained per-message key (best-effort).
1208        if crate::db::community::get_message_key(message_id).map(|k| k.is_some()).unwrap_or(false) {
1209            let _ = service::delete_message(&transport, message_id).await;
1210        }
1211        // Layer 2 — cooperative tombstone so peers hide it.
1212        self.publish_community_control(
1213            &channel_id, stored_event::event_kind::COMMUNITY_DELETE, "", message_id, &[],
1214        ).await?;
1215        // Layer 3 — best-effort attachment blob delete.
1216        if !attachment_urls.is_empty() {
1217            if let Some(client) = state::nostr_client() {
1218                if let Ok(signer) = client.signer().await {
1219                    crate::blossom::delete_blobs_best_effort(signer, attachment_urls);
1220                }
1221            }
1222        }
1223        // Local removal.
1224        let removed_chat = {
1225            let mut st = state::STATE.lock().await;
1226            st.remove_message(message_id).map(|(cid, _)| cid)
1227        };
1228        let _ = crate::db::events::delete_event(message_id).await;
1229        traits::emit_event_json("message_removed", serde_json::json!({
1230            "id": message_id, "chat_id": removed_chat.as_deref().unwrap_or(&channel_id), "reason": "deleted",
1231        }));
1232        Ok(())
1233    }
1234
1235    /// Shared community control-event publish (reaction / edit / delete tombstone): build the
1236    /// inner-typed envelope, sign, send over the community transport, then locally echo + persist + emit.
1237    async fn publish_community_control(
1238        &self,
1239        channel_id: &str,
1240        kind: u16,
1241        content: &str,
1242        target: &str,
1243        emoji_tags: &[crate::types::EmojiTag],
1244    ) -> Result<()> {
1245        use crate::community::{envelope, inbound, service, transport::LiveTransport};
1246        let (community, channel) = self.resolve_channel(channel_id)?;
1247        let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1248        let ms = std::time::SystemTime::now()
1249            .duration_since(std::time::UNIX_EPOCH)
1250            .map(|d| d.as_millis() as u64)
1251            .unwrap_or(0);
1252        let unsigned = envelope::build_inner_typed(
1253            author_pk, &channel.id, channel.epoch, kind, content, ms, Some(target), emoji_tags,
1254        );
1255        let client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1256        let signer = client.signer().await.map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
1257        let inner = unsigned.sign(&signer).await.map_err(|e| VectorError::Other(format!("sign: {e}")))?;
1258        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1259        let outer = service::send_signed_message(&transport, &community, &channel, &inner)
1260            .await.map_err(VectorError::Other)?;
1261        // Local echo + persist + emit (relay echo dedups on inner id).
1262        let outcome = {
1263            let mut st = state::STATE.lock().await;
1264            inbound::process_incoming(&mut st, &outer, &channel, &author_pk)
1265        };
1266        if let Some(inbound::IncomingEvent::Updated { target_id, message, edit_event }) = outcome {
1267            if let Some(ev) = edit_event {
1268                let mut ev = (*ev).clone();
1269                if let Ok(cid) = crate::db::id_cache::get_chat_id_by_identifier(channel_id) { ev.chat_id = cid; }
1270                let _ = crate::db::events::save_event(&ev).await;
1271            } else {
1272                let _ = crate::db::events::save_message(channel_id, &message).await;
1273            }
1274            traits::emit_event_json("message_update", serde_json::json!({
1275                "old_id": target_id, "message": &message, "chat_id": channel_id,
1276            }));
1277        }
1278        Ok(())
1279    }
1280
1281    /// Fetch the latest page of a Community channel from relays, ingesting messages,
1282    /// reactions, edits, and deletes. Returns the count of brand-new messages applied.
1283    /// Returns `(new_message_count, warnings)`. `warnings` are NON-FATAL errors hit during the sync
1284    /// (catch-up, control fold, read-cut resume) — surfaced rather than swallowed so a headless caller is
1285    /// never blind to "the sync ran but a re-founding couldn't be resumed."
1286    pub async fn sync_community_channel(&self, channel_id: &str, limit: usize) -> Result<(usize, Vec<String>)> {
1287        use crate::community::{inbound, send, service, transport::LiveTransport};
1288        let my_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1289        let (community, _) = self.resolve_channel(channel_id)?;
1290        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1291        let mut warnings: Vec<String> = Vec::new();
1292
1293        // FIRST: walk any base (server-root) rotation — a privatize / private-ban rekey advances the
1294        // epoch and re-anchors the control plane under the NEW root, so we must follow it BEFORE reading
1295        // control/messages or we'd look at stale-epoch pseudonyms and silently fall off. No-op (one cheap
1296        // probe) when there's been no rotation. Re-resolve after: the base epoch + root may have advanced.
1297        // An AUTHORIZED base rotation that excluded us (private ban / read-cut) is a removal: erase local
1298        // community data, exactly like an observed banlist/kick. This is the catch-all for a cut member who
1299        // can no longer decrypt the new control plane to read the banlist the normal way (`am_i_banned`).
1300        match service::catch_up_server_root(&transport, &community).await {
1301            Ok(c) if c.removed => {
1302                // ban-rekey exclusion is a self-removal → retain the held epoch keys for later self-scrub.
1303                let _ = crate::db::community::delete_community_retain_keys(&community.id.to_hex());
1304                return Ok((0, warnings));
1305            }
1306            Ok(_) => {}
1307            Err(e) => warnings.push(format!("base catch-up failed: {e}")),
1308        }
1309        let (community, _) = self.resolve_channel(channel_id)?;
1310
1311        // Headless clients have no realtime control-plane subscription, so fold the latest control editions
1312        // here (the desktop does the same on its own latest-page sync). Banlist FIRST: a ban that landed on
1313        // us self-removes like a kick (drop keys + local data, no rejoin). Then roles, the per-creator invite
1314        // links (Public/Private mode), and metadata (name/description/icon/channel-name) — so a rename, role,
1315        // ban, or mode change reaches this member on sync, not just in a realtime client.
1316        if let Err(e) = service::fetch_and_apply_control(&transport, &community).await {
1317            warnings.push(format!("control fold failed: {e}"));
1318        }
1319        if service::am_i_banned(&community) {
1320            // ban self-removal → retain the held epoch keys for later self-scrub.
1321            let _ = crate::db::community::delete_community_retain_keys(&community.id.to_hex());
1322            return Ok((0, warnings));
1323        }
1324        // Walk any CHANNEL rekey so we hold the current channel key before paging it, then re-resolve so the
1325        // batch below carries the fresh channel epoch/key + the freshly-folded banned set + metadata.
1326        let (community, channel) = self.resolve_channel(channel_id)?;
1327        if let Err(e) = service::catch_up_channel_rekeys(&transport, &community, &channel.id).await {
1328            warnings.push(format!("channel catch-up failed: {e}"));
1329        }
1330        // Resume any interrupted re-founding (a privatize/ban whose rotation aborted mid-way — e.g. a
1331        // transient relay miss on the re-anchor). The GUI's sync did this; the agent's path did NOT, so an
1332        // interrupted re-founding stayed `read_cut_pending` forever (channel frozen). Best-effort + surfaced.
1333        let (community, _) = self.resolve_channel(channel_id)?;
1334        if let Err(e) = service::retry_pending_read_cut(&transport, &community).await {
1335            warnings.push(format!("read-cut resume failed: {e}"));
1336        }
1337        let (community, channel) = self.resolve_channel(channel_id)?;
1338
1339        let events = send::fetch_channel_page(&transport, &community, &channel, None, None, limit.max(1))
1340            .await
1341            .map_err(VectorError::Other)?;
1342        let outcomes = {
1343            let mut st = state::STATE.lock().await;
1344            inbound::process_channel_batch(&mut st, &events, &channel, &my_pk)
1345        };
1346        let mut new = 0usize;
1347        for o in &outcomes {
1348            match o {
1349                inbound::IncomingEvent::NewMessage(m) => {
1350                    let _ = crate::db::events::save_message(channel_id, m).await;
1351                    new += 1;
1352                }
1353                inbound::IncomingEvent::Updated { message, .. } => {
1354                    let _ = crate::db::events::save_message(channel_id, message).await;
1355                }
1356                inbound::IncomingEvent::Removed { target_id } => {
1357                    let _ = crate::db::events::delete_event(target_id).await;
1358                }
1359                inbound::IncomingEvent::Presence { npub, joined, event_id, created_at, invited_by, invited_label } => {
1360                    let et = if *joined {
1361                        crate::stored_event::SystemEventType::MemberJoined
1362                    } else {
1363                        crate::stored_event::SystemEventType::MemberLeft
1364                    };
1365                    // attribution persisted in the note: "invited_by[|label]".
1366                    let note = invited_by.as_ref().map(|by| match invited_label {
1367                        Some(l) if !l.is_empty() => format!("{by}|{l}"),
1368                        _ => by.clone(),
1369                    });
1370                    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;
1371                }
1372                inbound::IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, event_id, created_at } => {
1373                    // Persist only (DM-parity row) — the miniapp layer bootstraps from the DB at
1374                    // game-open. Live gossip-feed pokes are the realtime subscription's job.
1375                    community::service::persist_webxdc_signal(
1376                        channel_id, npub, topic_id, node_addr.as_deref(), event_id, *created_at,
1377                    ).await;
1378                }
1379                inbound::IncomingEvent::Kicked { community_id }
1380                | inbound::IncomingEvent::SelfLeft { community_id } => {
1381                    // self-removal (kick of me, or a leave I/another device authored): drop the
1382                    // community's local state but RETAIN the held epoch keys (later self-scrub). The core-level
1383                    // half of leaving; a client shell layers on subscription-refresh + chat-row teardown + UI.
1384                    // Stop the batch — the community is gone, so later same-batch writes would orphan rows.
1385                    let _ = crate::db::community::delete_community_retain_keys(community_id);
1386                    break;
1387                }
1388                inbound::IncomingEvent::Typing { .. } => {
1389                    // Realtime-only ephemeral signal; never fetched in a sync batch. No-op.
1390                }
1391            }
1392        }
1393        Ok((new, warnings))
1394    }
1395
1396    /// Observed members of a Community (best-effort: those who've posted or announced a join,
1397    /// minus anyone who's left or is banned). Each entry is `{npub, last_active}`.
1398    pub async fn get_community_members(&self, community_id: &str) -> Vec<serde_json::Value> {
1399        crate::db::community::community_member_activity(community_id)
1400            .unwrap_or_default()
1401            .into_iter()
1402            .map(|(npub, last_active)| serde_json::json!({ "npub": npub, "last_active": last_active }))
1403            .collect()
1404    }
1405
1406    // ── Community admin actions ── role-gated; vector-core re-checks authority on every action and peers
1407    // re-verify against the owner-rooted roster, so these can't forge standing. A bunker account can't ban
1408    // in a private community (the rekey needs a raw local key).
1409
1410    fn load_community_hex(community_id: &str) -> Result<crate::community::Community> {
1411        use crate::community::CommunityId;
1412        if community_id.len() != 64 {
1413            return Err(VectorError::Other("malformed community id".into()));
1414        }
1415        crate::db::community::load_community(&CommunityId(crate::simd::hex::hex_to_bytes_32(community_id)))
1416            .map_err(VectorError::Other)?
1417            .ok_or_else(|| VectorError::Other("community not found".into()))
1418    }
1419
1420    fn admin_role_id_of(community_id: &str) -> Result<String> {
1421        let roles = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
1422        roles.roles.iter()
1423            .find(|r| matches!(r.scope, crate::community::roles::RoleScope::Server)
1424                && r.permissions.contains(crate::community::roles::Permissions::ADMIN_ALL))
1425            .map(|r| r.role_id.clone())
1426            .ok_or_else(|| VectorError::Other("admin role not found (roster not synced?)".into()))
1427    }
1428
1429    /// My effective management capabilities in a community (role engine — owner is just position 0). Use to
1430    /// confirm a promotion/demotion landed.
1431    pub fn community_capabilities(&self, community_id: &str) -> Result<serde_json::Value> {
1432        use crate::community::service;
1433        let community = Self::load_community_hex(community_id)?;
1434        let caps = service::caller_capabilities(&community);
1435        let manage_admin_role = Self::admin_role_id_of(community_id).ok()
1436            .map(|rid| service::caller_can_manage_role_id(&community, &rid))
1437            .unwrap_or(false);
1438        Ok(serde_json::json!({
1439            "manage_metadata": caps.manage_metadata, "manage_channels": caps.manage_channels,
1440            "create_invite": caps.create_invite, "kick": caps.kick, "ban": caps.ban,
1441            "manage_messages": caps.manage_messages, "manage_roles": caps.manage_roles,
1442            "manage_admin_role": manage_admin_role,
1443        }))
1444    }
1445
1446    /// The community's owner npub + the admin npubs (role overview).
1447    pub fn community_roles(&self, community_id: &str) -> Result<serde_json::Value> {
1448        use nostr_sdk::prelude::{PublicKey, ToBech32};
1449        let community = Self::load_community_hex(community_id)?;
1450        let owner = community.owner_attestation.as_ref()
1451            .and_then(|att| crate::community::owner::verify_owner_attestation(att, &community.id.to_hex()))
1452            .and_then(|pk| ToBech32::to_bech32(&pk).ok());
1453        let roles = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
1454        let admins: Vec<String> = roles.grants.iter().filter(|g| roles.is_admin(&g.member))
1455            .filter_map(|g| PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()))
1456            .collect();
1457        Ok(serde_json::json!({ "owner": owner, "admins": admins }))
1458    }
1459
1460    /// Grant a member the @admin role. Requires MANAGE_ROLES + outranking the role's position.
1461    pub async fn grant_admin(&self, community_id: &str, npub: &str) -> Result<()> {
1462        use crate::community::{service, transport::LiveTransport};
1463        let member = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
1464        let community = Self::load_community_hex(community_id)?;
1465        let role_id = Self::admin_role_id_of(community_id)?;
1466        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1467        service::grant_role(&transport, &community, member, &role_id).await.map_err(VectorError::Other)
1468    }
1469
1470    /// Revoke a member's @admin role.
1471    pub async fn revoke_admin(&self, community_id: &str, npub: &str) -> Result<()> {
1472        use crate::community::{service, transport::LiveTransport};
1473        let member = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
1474        let community = Self::load_community_hex(community_id)?;
1475        let role_id = Self::admin_role_id_of(community_id)?;
1476        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1477        service::revoke_role(&transport, &community, member, &role_id).await.map_err(VectorError::Other)
1478    }
1479
1480    /// Cooperatively kick a member (3309) — they self-remove but can rejoin. Requires KICK + outrank.
1481    pub async fn kick_member(&self, community_id: &str, npub: &str) -> Result<()> {
1482        use crate::community::{service, transport::LiveTransport};
1483        let hex = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?.to_hex();
1484        let community = Self::load_community_hex(community_id)?;
1485        let channel = community.channels.first().ok_or_else(|| VectorError::Other("community has no channel".into()))?;
1486        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1487        service::publish_kick(&transport, &community, channel, &hex).await.map(|_| ()).map_err(VectorError::Other)
1488    }
1489
1490    /// Ban (`true`) or unban (`false`) a member. Ban is terminal (no rejoin); in a private community it also
1491    /// fires the read-cut rekey (needs a local key). Requires BAN + outrank.
1492    pub async fn set_member_banned(&self, community_id: &str, npub: &str, banned: bool) -> Result<()> {
1493        use crate::community::{service, transport::LiveTransport};
1494        let hex = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?.to_hex();
1495        let community = Self::load_community_hex(community_id)?;
1496        // Recompute the full list (latest-wins): drop any existing entry, then add if banning.
1497        let mut list = crate::db::community::get_community_banlist(community_id).map_err(VectorError::Other)?;
1498        list.retain(|h| h != &hex);
1499        if banned { list.push(hex); }
1500        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1501        service::publish_banlist(&transport, &community, &list).await.map_err(VectorError::Other)
1502    }
1503
1504    /// Owner dissolution / "Delete Community": publish the terminal GroupDissolved tombstone (and
1505    /// retire the owner's own invite links, no rekey), sealing the community permanently. Owner-only
1506    /// (re-verified cryptographically in `service::dissolve_community`); irreversible.
1507    pub async fn dissolve_community(&self, community_id: &str) -> Result<()> {
1508        use crate::community::{service, transport::LiveTransport};
1509        let community = Self::load_community_hex(community_id)?;
1510        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1511        service::dissolve_community(&transport, &community).await.map_err(VectorError::Other)
1512    }
1513
1514    /// Edit community metadata (name / description) as an authorized member (MANAGE_METADATA). `None` leaves
1515    /// a field unchanged; an empty description clears it.
1516    pub async fn edit_community_metadata(&self, community_id: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
1517        use crate::community::{service, transport::LiveTransport};
1518        let mut community = Self::load_community_hex(community_id)?;
1519        if let Some(n) = name { community.name = n.to_string(); }
1520        if let Some(d) = description { community.description = if d.is_empty() { None } else { Some(d.to_string()) }; }
1521        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1522        service::republish_community_metadata(&transport, &community).await.map_err(VectorError::Other)
1523    }
1524
1525    /// Leave a Community: announce a best-effort "left" presence (before dropping keys), then
1526    /// drop the held keys + local channel chats. You need a fresh invite to rejoin.
1527    pub async fn leave_community(&self, community_id: &str) -> Result<()> {
1528        use crate::community::{transport::LiveTransport, CommunityId};
1529        if community_id.len() != 64 {
1530            return Err(VectorError::Other("malformed community id".into()));
1531        }
1532        let id = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
1533        let community = crate::db::community::load_community(&id).map_err(VectorError::Other)?;
1534        let channel_ids: Vec<String> = community
1535            .as_ref()
1536            .map(|c| c.channels.iter().map(|ch| ch.id.to_hex()).collect())
1537            .unwrap_or_default();
1538        // "Left" announcement BEFORE dropping keys (afterward we can't sign/seal into the channel).
1539        if let Some(ref c) = community {
1540            if let Some(primary) = c.channels.first() {
1541                let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1542                let _ = crate::community::service::publish_presence(&transport, c, primary, false, None).await;
1543            }
1544        }
1545        // voluntary leave is a self-removal → retain the held epoch keys for later self-scrub.
1546        crate::db::community::delete_community_retain_keys(community_id).map_err(VectorError::Other)?;
1547        {
1548            let mut st = state::STATE.lock().await;
1549            st.chats.retain(|c| !channel_ids.contains(&c.id));
1550        }
1551        Ok(())
1552    }
1553
1554    /// Resolve a channel id to its owning Community + the Channel (with its secret key).
1555    fn resolve_channel(
1556        &self,
1557        channel_id: &str,
1558    ) -> Result<(crate::community::Community, crate::community::Channel)> {
1559        use crate::community::CommunityId;
1560        let community_id = crate::db::community::community_id_for_channel(channel_id)
1561            .map_err(VectorError::Other)?
1562            .ok_or_else(|| VectorError::Other("Unknown Community channel".into()))?;
1563        if community_id.len() != 64 {
1564            return Err(VectorError::Other("malformed community id".into()));
1565        }
1566        let community = crate::db::community::load_community(&CommunityId(
1567            crate::simd::hex::hex_to_bytes_32(&community_id),
1568        ))
1569        .map_err(VectorError::Other)?
1570        .ok_or_else(|| VectorError::Other("Community not found".into()))?;
1571        let channel = community
1572            .channels
1573            .iter()
1574            .find(|c| c.id.to_hex() == channel_id)
1575            .cloned()
1576            .ok_or_else(|| VectorError::Other("Channel not found in Community".into()))?;
1577        Ok((community, channel))
1578    }
1579
1580
1581    /// Sync DM history from relays using NIP-77 negentropy set reconciliation.
1582    ///
1583    /// Reconciles local wrapper history with relay state, fetches missing events,
1584    /// and processes them through the standard prepare → commit pipeline.
1585    ///
1586    /// Returns (total_events, new_messages).
1587    ///
1588    /// ```no_run
1589    /// # async fn example() -> vector_core::Result<()> {
1590    /// let core = vector_core::VectorCore;
1591    /// // Sync last 7 days of DMs
1592    /// let (events, new) = core.sync_dms(Some(7), &vector_core::NoOpEventHandler).await?;
1593    /// println!("Processed {} events, {} new messages", events, new);
1594    /// # Ok(())
1595    /// # }
1596    /// ```
1597    pub async fn sync_dms(
1598        &self,
1599        since_days: Option<u64>,
1600        handler: &dyn InboundEventHandler,
1601    ) -> Result<(u32, u32)> {
1602        use futures_util::StreamExt;
1603        use nostr_sdk::prelude::*;
1604
1605        let client = state::nostr_client()
1606            .ok_or(VectorError::Other("Not connected".into()))?;
1607        let my_pk = state::my_public_key()
1608            .ok_or(VectorError::Other("Not logged in".into()))?;
1609
1610        // Load known wrapper IDs + timestamps for negentropy fingerprinting
1611        let all_items = db::wrappers::load_negentropy_items().unwrap_or_default();
1612
1613        // Filter items to time window (or use all for full sync)
1614        let (items, filter) = if let Some(days) = since_days {
1615            let since_ts = Timestamp::now().as_secs().saturating_sub(days * 24 * 3600);
1616            let items: Vec<(EventId, Timestamp)> = all_items.iter()
1617                .filter(|(_, ts)| ts.as_secs() >= since_ts)
1618                .cloned()
1619                .collect();
1620            let filter = Filter::new()
1621                .pubkey(my_pk)
1622                .kind(Kind::GiftWrap)
1623                .since(Timestamp::from_secs(since_ts));
1624            (items, filter)
1625        } else {
1626            let filter = Filter::new()
1627                .pubkey(my_pk)
1628                .kind(Kind::GiftWrap);
1629            (all_items, filter)
1630        };
1631
1632        log_info!("[SyncDMs] {} negentropy items, since_days={:?}", items.len(), since_days);
1633
1634        // Dry-run negentropy: exchange fingerprints to identify missing events
1635        let sync_opts = nostr_sdk::SyncOptions::new()
1636            .direction(nostr_sdk::SyncDirection::Down)
1637            .initial_timeout(std::time::Duration::from_secs(10))
1638            .dry_run();
1639
1640        // Race all relays — first to reconcile drives the fetch
1641        let relay_map = client.relays().await;
1642        let all_relays: Vec<(RelayUrl, Relay)> = relay_map.iter()
1643            .map(|(url, relay)| (url.clone(), relay.clone()))
1644            .collect();
1645        drop(relay_map);
1646
1647        let mut relay_futs = futures_util::stream::FuturesUnordered::new();
1648        for (url, relay) in &all_relays {
1649            let url = url.clone();
1650            let relay = relay.clone();
1651            let f = filter.clone();
1652            let i = items.clone();
1653            let o = sync_opts.clone();
1654            relay_futs.push(async move {
1655                let result = tokio::time::timeout(
1656                    std::time::Duration::from_secs(10),
1657                    relay.sync_with_items(f, i, &o),
1658                ).await;
1659                (url, result)
1660            });
1661        }
1662
1663        // Collect missing IDs from all relays
1664        let mut all_missing: std::collections::HashSet<EventId> = std::collections::HashSet::new();
1665        while let Some((url, result)) = relay_futs.next().await {
1666            match result {
1667                Ok(Ok(recon)) => {
1668                    let count = recon.remote.len();
1669                    all_missing.extend(recon.remote);
1670                    log_info!("[SyncDMs] {} reconciled: {} missing", url, count);
1671                }
1672                Ok(Err(e)) => log_warn!("[SyncDMs] {} failed: {}", url, e),
1673                Err(_) => log_warn!("[SyncDMs] {} timed out (10s)", url),
1674            }
1675        }
1676
1677        if all_missing.is_empty() {
1678            log_info!("[SyncDMs] No missing events");
1679            return Ok((0, 0));
1680        }
1681
1682        // Fetch missing events in batches
1683        log_info!("[SyncDMs] Fetching {} missing events", all_missing.len());
1684        let ids: Vec<EventId> = all_missing.into_iter().collect();
1685        let relay_strs: Vec<String> = client.relays().await.keys()
1686            .map(|u| u.to_string()).collect();
1687
1688        let mut total_events = 0u32;
1689        let mut new_messages = 0u32;
1690        const BATCH_SIZE: usize = 500;
1691
1692        for batch in ids.chunks(BATCH_SIZE) {
1693            let f = Filter::new().ids(batch.to_vec()).kind(Kind::GiftWrap);
1694            match client.stream_events_from(
1695                relay_strs.clone(), f,
1696                std::time::Duration::from_secs(30),
1697            ).await {
1698                Ok(stream) => {
1699                    let client_clone = client.clone();
1700                    let prepared_stream = stream
1701                        .map(move |event| {
1702                            let c = client_clone.clone();
1703                            tokio::spawn(async move {
1704                                event_handler::prepare_event(event, &c, my_pk).await
1705                            })
1706                        })
1707                        .buffer_unordered(8);
1708                    tokio::pin!(prepared_stream);
1709
1710                    while let Some(result) = prepared_stream.next().await {
1711                        total_events += 1;
1712                        if let Ok(prepared) = result {
1713                            if event_handler::commit_prepared_event(prepared, false, handler).await {
1714                                new_messages += 1;
1715                            }
1716                        }
1717                    }
1718                }
1719                Err(e) => log_warn!("[SyncDMs] Batch fetch error: {}", e),
1720            }
1721        }
1722
1723        log_info!("[SyncDMs] Complete: {} events processed, {} new messages", total_events, new_messages);
1724        Ok((total_events, new_messages))
1725    }
1726
1727    // ========================================================================
1728    // Event Subscription
1729    // ========================================================================
1730
1731    /// Subscribe to incoming DM events (NIP-17 GiftWraps).
1732    ///
1733    /// Returns the subscription ID for use in a custom notification loop.
1734    /// For a complete listen-and-process loop, use [`listen()`](Self::listen) instead.
1735    pub async fn subscribe_dms(&self) -> Result<nostr_sdk::SubscriptionId> {
1736        use nostr_sdk::prelude::*;
1737        let client = state::nostr_client()
1738            .ok_or(VectorError::Other("Not connected".into()))?;
1739        let my_pk = state::my_public_key()
1740            .ok_or(VectorError::Other("Not logged in".into()))?;
1741
1742        let filter = Filter::new()
1743            .pubkey(my_pk)
1744            .kind(Kind::GiftWrap)
1745            .limit(0);
1746
1747        let output = client.subscribe(filter, None).await
1748            .map_err(|e| VectorError::Nostr(e.to_string()))?;
1749        Ok(output.val)
1750    }
1751
1752    /// Catch up every locally-held Community: fold control / re-foundings / rekeys / banlist and
1753    /// fetch recent messages into local state for each channel. State-only (does not replay to an
1754    /// [`InboundEventHandler`]). Called at `listen()` start and periodically for outage resilience;
1755    /// also safe to call manually after a known disconnect.
1756    pub async fn sync_communities(&self) -> Result<()> {
1757        let ids = db::community::list_community_ids().map_err(VectorError::from)?;
1758        for id in ids {
1759            if let Ok(Some(community)) = db::community::load_community(&id) {
1760                for ch in &community.channels {
1761                    let _ = self.sync_community_channel(&ch.id.to_hex(), 50).await;
1762                }
1763            }
1764        }
1765        Ok(())
1766    }
1767
1768    /// Start listening for incoming DMs.
1769    ///
1770    /// Blocks until the client disconnects. Processes GiftWraps
1771    /// (DMs, files) → prepare_event → commit_prepared_event.
1772    ///
1773    /// ```no_run
1774    /// use vector_core::*;
1775    /// use std::sync::Arc;
1776    ///
1777    /// struct MyBot;
1778    /// impl InboundEventHandler for MyBot {
1779    ///     fn on_dm_received(&self, chat_id: &str, msg: &Message, _is_new: bool) {
1780    ///         if msg.mine { return; }
1781    ///         let to = chat_id.to_string();
1782    ///         let reply = format!("Echo: {}", msg.content);
1783    ///         tokio::spawn(async move {
1784    ///             let _ = VectorCore.send_dm(&to, &reply).await;
1785    ///         });
1786    ///     }
1787    /// }
1788    ///
1789    /// # async fn example() -> vector_core::Result<()> {
1790    /// let core = VectorCore::init(CoreConfig {
1791    ///     data_dir: "/tmp/bot-data".into(),
1792    ///     event_emitter: None,
1793    /// })?;
1794    /// core.login("nsec1...", None).await?;
1795    /// core.listen(Arc::new(MyBot)).await?;
1796    /// # Ok(())
1797    /// # }
1798    /// ```
1799    pub async fn listen(&self, handler: Arc<dyn InboundEventHandler>) -> Result<()> {
1800        use nostr_sdk::prelude::*;
1801
1802        let client = state::nostr_client()
1803            .ok_or(VectorError::Other("Not connected".into()))?;
1804        let my_pk = state::my_public_key()
1805            .ok_or(VectorError::Other("Not logged in".into()))?;
1806
1807        // Outage resilience — catch up on connect, then re-sync periodically.
1808        //
1809        // Catch up BEFORE going realtime so a bot that was offline folds any missed re-foundings /
1810        // metadata / banlist changes (and recent messages) into local state, and subscribes at the
1811        // CURRENT epoch pseudonyms. This is state-only: historical messages are not replayed to the
1812        // handler (matches the gateway model) — query them via `get_messages`.
1813        let _ = self.sync_communities().await;
1814        let _ = self.sync_dms(None, &NoOpEventHandler).await;
1815
1816        // Subscribe to DMs (GiftWraps) AND Community channel events — one loop dispatches both
1817        // through the same handler, so `on_dm_received`/`on_community_message` share a sink.
1818        let dm_sub_id = self.subscribe_dms().await?;
1819        community::realtime::refresh_subscription(&client).await;
1820
1821        // Outage resilience via the relay Monitor — event-driven, not polling.
1822        //
1823        // (1) Reconnect-driven catch-up: a `limit(0)` realtime sub never replays what was published
1824        // while we were down, so a relay (re)connecting is exactly when we must catch up. On each
1825        // Connected transition we refold consensus + reconcile DMs (NIP-77 negentropy → only the
1826        // diff) and re-track the realtime sub at the current epochs. Idle when healthy. Stops on swap.
1827        if let Some(monitor) = client.monitor() {
1828            let mut rx = monitor.subscribe();
1829            let session = state::SessionGuard::capture();
1830            tokio::spawn(async move {
1831                // Debounce reconnect bursts: StatusChanged is per-relay, but one catch-up queries the
1832                // whole pool — so coalesce Connected transitions within a short window into one resync.
1833                let mut last_resync: Option<std::time::Instant> = None;
1834                while let Ok(notification) = rx.recv().await {
1835                    if !session.is_valid() {
1836                        return;
1837                    }
1838                    let MonitorNotification::StatusChanged { status, .. } = notification;
1839                    if status == RelayStatus::Connected {
1840                        if last_resync.is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(3)) {
1841                            continue;
1842                        }
1843                        let _ = VectorCore.sync_communities().await;
1844                        let _ = VectorCore.sync_dms(None, &NoOpEventHandler).await;
1845                        if let Some(c) = state::nostr_client() {
1846                            community::realtime::refresh_subscription(&c).await;
1847                        }
1848                        last_resync = Some(std::time::Instant::now());
1849                    }
1850                }
1851            });
1852        }
1853
1854        // (2) Health probe: a relay can report Connected while silently dead. Every 60s probe each
1855        // with a tiny query + timeout; a zombie is force-reconnected (which fires the monitor above
1856        // → catch-up), and Disconnected/Terminated relays are reconnected directly.
1857        {
1858            let client_health = client.clone();
1859            let session = state::SessionGuard::capture();
1860            tokio::spawn(async move {
1861                tokio::time::sleep(std::time::Duration::from_secs(30)).await; // warm-up
1862                loop {
1863                    if !session.is_valid() {
1864                        return;
1865                    }
1866                    for (url, relay) in client_health.relays().await {
1867                        match relay.status() {
1868                            RelayStatus::Connected => {
1869                                let probe = tokio::time::timeout(
1870                                    std::time::Duration::from_secs(10),
1871                                    client_health.fetch_events_from(
1872                                        vec![url.to_string()],
1873                                        Filter::new().kind(Kind::Metadata).limit(1),
1874                                        std::time::Duration::from_secs(8),
1875                                    ),
1876                                )
1877                                .await;
1878                                if !matches!(probe, Ok(Ok(_))) {
1879                                    let _ = relay.disconnect();
1880                                    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1881                                    let _ = relay.try_connect(std::time::Duration::from_secs(10)).await;
1882                                }
1883                            }
1884                            RelayStatus::Terminated | RelayStatus::Disconnected => {
1885                                let _ = relay.try_connect(std::time::Duration::from_secs(10)).await;
1886                            }
1887                            _ => {}
1888                        }
1889                    }
1890                    tokio::time::sleep(std::time::Duration::from_secs(60)).await;
1891                }
1892            });
1893        }
1894
1895        let client_for_closure = client.clone();
1896
1897        client.handle_notifications(move |notification| {
1898            let handler = handler.clone();
1899            let c = client_for_closure.clone();
1900            let dm_sid = dm_sub_id.clone();
1901            async move {
1902                if let RelayPoolNotification::Event { event, subscription_id, .. } = notification {
1903                    if subscription_id == dm_sid {
1904                        // DMs, files, reactions
1905                        let prepared = event_handler::prepare_event(*event, &c, my_pk).await;
1906                        event_handler::commit_prepared_event(prepared, true, &*handler).await;
1907                    } else if community::realtime::subscription_id().await.as_ref() == Some(&subscription_id) {
1908                        // Community channel messages / reactions / edits / control editions
1909                        let session = state::SessionGuard::capture();
1910                        community::realtime::dispatch_event(&session, *event, handler.clone()).await;
1911                    }
1912                }
1913                Ok(false)
1914            }
1915        }).await.map_err(|e| VectorError::Nostr(e.to_string()))?;
1916
1917        Ok(())
1918    }
1919
1920    /// Disconnect and clean up.
1921    pub async fn logout(&self) {
1922        if let Some(client) = state::nostr_client() {
1923            let _ = client.disconnect().await;
1924        }
1925        db::close_database();
1926    }
1927
1928    /// Tear down the current session for an in-process account swap — the account-agnostic core of
1929    /// the app's `reset_session()`. Advances the session generation FIRST so any background task
1930    /// holding a `SessionGuard` short-circuits before it can touch the next account's storage; shuts
1931    /// the client down (which ends any `listen()` notification loop bound to it, so the old account's
1932    /// events can't land in the new account's DB); closes the DB pool; and clears the key vaults plus
1933    /// all in-memory per-account state. Follow with `login()` to bind the next account, then re-attach
1934    /// `listen()`. (The app's `reset_session()` additionally clears Tauri-only caches it owns.)
1935    pub async fn swap_session(&self) {
1936        // FIRST — invalidate every captured guard before any teardown begins.
1937        state::bump_session_generation();
1938
1939        // Shut the client down before anything else: this detaches relay subscriptions and ends the
1940        // prior `listen()` loop, so it stops firing the old account's events into the new session.
1941        if let Some(client) = state::take_nostr_client() {
1942            let _ = client.shutdown().await;
1943        }
1944        db::close_database();
1945
1946        // Key vaults + transient secrets.
1947        state::ENCRYPTION_KEY.clear(&[&state::MY_SECRET_KEY]);
1948        state::MY_SECRET_KEY.clear(&[&state::ENCRYPTION_KEY]);
1949        {
1950            use zeroize::Zeroize;
1951            if let Ok(mut g) = state::MNEMONIC_SEED.lock() {
1952                if let Some(s) = g.as_mut() { s.zeroize(); }
1953                *g = None;
1954            }
1955            if let Ok(mut g) = state::PENDING_NSEC.lock() {
1956                if let Some(s) = g.as_mut() { s.zeroize(); }
1957                *g = None;
1958            }
1959        }
1960
1961        // In-memory per-account state owned by vector-core's globals.
1962        {
1963            let mut st = state::STATE.lock().await;
1964            st.profiles.clear();
1965            st.chats.clear();
1966            st.db_loaded = false;
1967            st.is_syncing = false;
1968        }
1969        state::WRAPPER_ID_CACHE.lock().await.clear();
1970        state::PENDING_EVENTS.lock().await.clear();
1971        state::set_active_chat(None);
1972        crate::profile::sync::clear_profile_sync_queue();
1973        crate::inbox_relays::clear_inbox_relay_cache();
1974        crate::emoji_packs::clear_nip65_cache();
1975        // Chat/user row-id caches are PER-ACCOUNT (row ids belong to the prior account's DB). Not clearing
1976        // them here let a swapped-in account resolve a channel/npub to the WRONG (prior-account) row id →
1977        // saves FK-failed silently + reads hit the wrong row (e.g. a community member vanished post-swap).
1978        crate::db::clear_id_caches();
1979        // Community sync RAM cache (page cursors, history-start, in-flight, invite preload) is
1980        // account-scoped — drop it so the next account can't read A's cursors/warmed pages. The
1981        // generation stamp self-invalidates too, but clear explicitly for parity with the GUI swap.
1982        crate::community::cache::clear();
1983        // Community realtime route/subscription state is account-scoped (channel keys + banned sets);
1984        // drop it so a swapped-in account can't listen on the prior account's pseudonyms.
1985        crate::community::realtime::clear().await;
1986        // Theme-pack emoji tags are account-scoped; leaving the prior account's set active would tag the
1987        // next account's outbound messages with A's theme shortcodes (leaking A's pack Blossom URLs). The
1988        // frontend re-registers the new account's theme, but only if it HAS one — clear to be safe.
1989        crate::emoji_packs::set_theme_emoji_tags(Vec::new());
1990    }
1991}
1992
1993#[cfg(test)]
1994mod facade_tests {
1995    use super::*;
1996
1997    /// SSRF regression: `download_attachment` must reject a private/link-local URL via
1998    /// `validate_url_not_private` BEFORE any network fetch (the URL is attacker-controlled).
1999    #[tokio::test]
2000    async fn download_attachment_rejects_private_url() {
2001        let att = crate::types::Attachment {
2002            url: "http://169.254.169.254/latest/meta-data/".to_string(),
2003            ..Default::default()
2004        };
2005        match VectorCore.download_attachment(&att).await {
2006            Err(VectorError::Other(msg)) => {
2007                assert!(msg.contains("Private/internal"), "expected SSRF rejection, got: {msg}")
2008            }
2009            other => panic!("expected SSRF rejection, got {other:?}"),
2010        }
2011    }
2012
2013    #[tokio::test]
2014    async fn download_attachment_rejects_empty_url() {
2015        let att = crate::types::Attachment::default();
2016        assert!(VectorCore.download_attachment(&att).await.is_err());
2017    }
2018}