Skip to main content

vector_sdk/
lib.rs

1//! # Vector SDK
2//!
3//! Build a private-messaging bot in about a dozen lines.
4//!
5//! [Vector](https://vectorapp.io) is a private, encrypted messenger. This SDK lets your
6//! bot send and receive messages, files, and reactions, join communities, and ride out
7//! network drops β€” without ever touching the protocol or encryption underneath.
8//!
9//! ```no_run
10//! use vector_sdk::VectorBot;
11//!
12//! #[tokio::main]
13//! async fn main() -> vector_sdk::Result<()> {
14//!     let bot = VectorBot::builder()
15//!         .nsec("nsec1...")          // or .mnemonic("twelve words ...")
16//!         .build()
17//!         .await?;
18//!
19//!     println!("Online as {}", bot.npub());
20//!
21//!     // Reply to everything. The SAME handler serves DMs *and* Community channels.
22//!     bot.on_message(|_bot, msg| async move {
23//!         if msg.is_mine() { return; }              // ignore our own messages
24//!         let _ = msg.reply(&format!("You said: {}", msg.text())).await;
25//!     }).await?;
26//!
27//!     Ok(())
28//! }
29//! ```
30//!
31//! That bot already handles direct messages *and* communities, reconnects after a
32//! network drop, and catches up on what it missed.
33//!
34//! ## One API, everywhere
35//!
36//! Your bot sends and receives through a **`Channel`** β€” a direct-message chat or a
37//! community channel, handled **identically**. You never branch on which it is:
38//! [`bot.channel(id)`](VectorBot::channel) opens either by id, and
39//! [`msg.reply(...)`](IncomingMessage::reply) answers wherever the message came from.
40//!
41//! ```no_run
42//! # use vector_sdk::VectorBot;
43//! # async fn run(bot: VectorBot, id: &str, msg_id: &str) -> vector_sdk::Result<()> {
44//! let chat = bot.channel(id);            // DM or Community channel β€” auto-detected
45//! chat.send("hi").await?;                //
46//! chat.react(msg_id, "πŸ‘").await?;       // identical surface either way
47//! chat.send_file("./photo.png").await?;  //
48//! chat.typing().await?;                  // "typing…" indicator
49//! # Ok(()) }
50//! ```
51//!
52//! ## What a bot can do
53//!
54//! | You want to… | …you call |
55//! | --- | --- |
56//! | Send / reply / edit / delete | [`Channel::send`] Β· [`reply`](Channel::reply) Β· [`edit`](Channel::edit) Β· [`delete`](Channel::delete) |
57//! | React (emoji or custom image) | [`Channel::react`] Β· [`react_custom`](Channel::react_custom) |
58//! | Send & receive files | [`Channel::send_file`] Β· [`VectorBot::download_attachment`] / [`save_attachment`](VectorBot::save_attachment) |
59//! | Receive messages | [`VectorBot::on_message`] |
60//! | Receive *everything* (joins, reactions, invites…) | [`VectorBot::on_event`] β†’ match on [`BotEvent`] |
61//! | Moderate a community | [`IncomingMessage::member`] β†’ [`Member::kick`] Β· [`ban`](Member::ban) Β· [`grant_admin`](Member::grant_admin) |
62//! | Manage a community | [`IncomingMessage::community`] / [`VectorBot::community`] β†’ [`Community`] |
63//! | Be invitable safely | [`builder().public()`](VectorBotBuilder::public) / [`whitelist(..)`](VectorBotBuilder::whitelist) |
64//! | Manage profiles | [`fetch_profile`](VectorBot::fetch_profile) Β· [`update_profile`](VectorBot::update_profile) Β· [`block`](VectorBot::block) … |
65//! | Anything else | [`bot.core()`](VectorBot::core) β†’ the full [`VectorCore`] facade |
66//!
67//! ## Receiving: `on_message` vs `on_event`
68//!
69//! [`on_message`](VectorBot::on_message) is the fast path β€” one async handler per
70//! inbound message, DMs and Community channels alike; a slow handler won't hold up the others.
71//!
72//! For everything beyond messages, [`on_event`](VectorBot::on_event) delivers the
73//! full stream as a [`BotEvent`] you `match` on β€” `Message`, `MessageUpdate` (a
74//! reaction/edit landed), `Delete`, `MemberJoin`, `MemberLeave`, `Typing`,
75//! `Invite`, and `Removed` (the bot was kicked/banned):
76//!
77//! ```no_run
78//! # use vector_sdk::{VectorBot, BotEvent};
79//! # async fn run(bot: VectorBot) -> vector_sdk::Result<()> {
80//! bot.on_event(|bot, event| async move {
81//!     match event {
82//!         BotEvent::Message(msg) if !msg.is_mine() => { let _ = msg.reply("hi").await; }
83//!         BotEvent::MemberJoin { channel_id, npub } => {
84//!             let _ = bot.channel(channel_id).send(&format!("welcome {}!", &npub[..12])).await;
85//!         }
86//!         _ => {}
87//!     }
88//! }).await?;
89//! # Ok(()) }
90//! ```
91//!
92//! ## Communities
93//!
94//! When a message comes from a community, you get the sender as a member you can act on
95//! directly:
96//!
97//! ```no_run
98//! # use vector_sdk::IncomingMessage;
99//! # async fn run(msg: IncomingMessage) -> vector_sdk::Result<()> {
100//! if let Some(member) = msg.member() {     // the sender, as a Member of this community
101//!     if !member.is_admin() {
102//!         member.ban().await?;             // or .kick() / .unban() / .grant_admin()
103//!     }
104//! }
105//! # Ok(()) }
106//! ```
107//!
108//! ## Public vs private bots
109//!
110//! A bot must accept invites to be useful in communities, but a *private* bot
111//! mustn't be spammable into random ones. Set the policy on the builder:
112//!
113//! ```no_run
114//! # use vector_sdk::VectorBot;
115//! # async fn run() -> vector_sdk::Result<()> {
116//! VectorBot::builder().nsec("nsec1...").public().build().await?;                 // accept from anyone
117//! VectorBot::builder().nsec("nsec1...").whitelist(["npub1owner…"]).build().await?; // only these accounts
118//! # Ok(()) }
119//! ```
120//!
121//! Auto-accept fires for live invites *and* for ones received while the bot was
122//! offline (swept on the next connect), so a restarted bot still joins what it
123//! was invited to. The default is [`InvitePolicy::Manual`] β€” see
124//! [`pending_invites`](VectorBot::pending_invites) / [`accept_invite`](VectorBot::accept_invite).
125//!
126//! ## Staying connected
127//!
128//! If the bot loses its connection, [`on_message`](VectorBot::on_message) /
129//! [`on_event`](VectorBot::on_event) reconnect on their own and catch up on what was
130//! missed. Your handler fires for messages that arrive while the
131//! bot is running; to read older history, use
132//! [`bot.core().get_messages(...)`](VectorCore).
133//!
134//! ## Identity: bring your own, or let the bot make one
135//!
136//! Supply a key with [`nsec`](VectorBotBuilder::nsec) / [`mnemonic`](VectorBotBuilder::mnemonic) β€”
137//! or supply nothing, and [`build`](VectorBotBuilder::build) **creates an identity on first run and
138//! persists it** (`identity.nsec`) in the bot's data directory, reusing the same one every run after.
139//! So a first bot needs zero setup:
140//!
141//! ```no_run
142//! # use vector_sdk::VectorBot;
143//! # async fn run() -> vector_sdk::Result<()> {
144//! let bot = VectorBot::builder().build().await?; // first run mints + stores an nsec; reused after
145//! println!("online as {}", bot.npub());
146//! # Ok(()) }
147//! ```
148//!
149//! It never mints a *fresh* key per run β€” the identity is stable, so the bot keeps its DMs and
150//! community memberships across restarts. Running several keyless bots? Give each its own
151//! [`data_dir`](VectorBotBuilder::data_dir) so they get distinct identities.
152//!
153//! ## Single identity per process
154//!
155//! `vector_core` is built on process-global state, so **one [`VectorBot`] owns
156//! the process's identity at a time**. Build one bot per process. (Multiple
157//! identities means multiple processes β€” or [`VectorCore::swap_session`] to
158//! switch the active account in place.)
159//!
160//! ## Reaching deeper
161//!
162//! Everything not surfaced here β€” creating communities, reading history, and
163//! lower-level controls β€” is one hop away via [`VectorBot::core`], which hands you
164//! the full [`VectorCore`] facade.
165//!
166//! ## Examples
167//!
168//! Runnable bots live in [`examples/`](https://github.com/VectorPrivacy/Vector/tree/master/crates/vector-sdk/examples):
169//!
170//! - **`echo_bot`** β€” the minimal hello-world; replies to every message.
171//! - **`slash_command_bot`** β€” a `/command` router (`/ping`, `/roll`, `/help`…).
172//! - **`ai_bot`** β€” an LLM chatbot with a typing indicator and threaded replies.
173//! - **`moderation_bot`** β€” welcomes joiners and auto-bans on a word filter.
174//! - **`whitelist_bot`** β€” a private bot that only joins communities it trusts.
175//! - **`file_bot`** / **`save_files_bot`** β€” send a file / receive and decrypt one.
176//!
177//! ```sh
178//! VECTOR_NSEC=nsec1... cargo run -p vector-sdk --example echo_bot
179//! ```
180
181use std::future::Future;
182use std::path::PathBuf;
183use std::sync::Arc;
184
185// Curated re-exports so downstream crates can depend only on `vector_sdk`.
186pub use vector_core::{
187    self, Attachment, AttachmentFile, CoreConfig, DeleteOutcome, EditEntry, EventEmitter,
188    ImageMetadata, InboundEventHandler, LoginResult, Message, NoOpEventHandler, Reaction, Result,
189    SendResult, SerializableChat, SiteMetadata, SlimProfile, Status, SyncPriority, VectorCore,
190    VectorError,
191};
192
193/// Alias for the SDK's error type.
194pub use vector_core::VectorError as Error;
195
196/// Re-exported Nostr primitives, so downstreams can depend only on `vector_sdk`.
197pub mod nostr {
198    pub use nostr_sdk::prelude::{FromBech32, Keys, PublicKey, SecretKey, ToBech32};
199}
200
201// Brings `PublicKey::from_bech32` / `.to_bech32()` into scope for id auto-detection + whitelist
202// normalization.
203use nostr_sdk::prelude::{FromBech32 as _, ToBech32 as _};
204
205mod commands;
206pub use commands::{CommandBuilder, CommandCtx, DISCOVERY_RELAYS};
207
208// ============================================================================
209// VectorBot
210// ============================================================================
211
212/// How a bot handles inbound Community invites (gift-wrapped invite bundles). Set on the builder
213/// with [`public`](VectorBotBuilder::public) / [`whitelist`](VectorBotBuilder::whitelist) /
214/// [`invite_policy`](VectorBotBuilder::invite_policy).
215#[derive(Clone, Debug)]
216pub enum InvitePolicy {
217    /// Don't auto-accept β€” invites are parked for manual handling via
218    /// [`VectorBot::pending_invites`] / [`VectorBot::accept_invite`]. (Default.)
219    Manual,
220    /// A **public** bot: auto-accept Community invites from anyone.
221    Public,
222    /// A **private** bot: auto-accept invites *only* when the inviter's npub is in this whitelist;
223    /// ignore all others. This is what keeps a bot from being spammed into random communities.
224    /// Entries must be bech32 `npub1…` (the form inviters are compared as). Prefer the
225    /// [`whitelist`](VectorBotBuilder::whitelist) builder, which normalizes hex β†’ bech32 for you.
226    Whitelist(Vec<String>),
227}
228
229impl InvitePolicy {
230    /// Whether an invite from `inviter_npub` should be auto-accepted under this policy.
231    fn accepts(&self, inviter_npub: Option<&str>) -> bool {
232        match self {
233            InvitePolicy::Manual => false,
234            InvitePolicy::Public => true,
235            InvitePolicy::Whitelist(list) => {
236                inviter_npub.is_some_and(|npub| list.iter().any(|w| w == npub))
237            }
238        }
239    }
240}
241
242/// A logged-in Vector bot: an identity connected to relays, ready to send and
243/// receive. Cheap to [`Clone`] β€” clones share the same underlying session.
244#[derive(Clone)]
245pub struct VectorBot {
246    core: VectorCore,
247    npub: String,
248    invite_policy: Arc<InvitePolicy>,
249    commands: Arc<commands::CommandRegistry>,
250}
251
252impl VectorBot {
253    /// Start building a bot. Provide a key with [`VectorBotBuilder::nsec`] (or
254    /// [`mnemonic`](VectorBotBuilder::mnemonic)), then call
255    /// [`build`](VectorBotBuilder::build).
256    pub fn builder() -> VectorBotBuilder {
257        VectorBotBuilder::default()
258    }
259
260    /// Generate a fresh random account secret key (bech32 `nsec`). Handy for
261    /// spinning up a brand-new bot identity.
262    pub fn generate_nsec() -> Result<String> {
263        VectorCore.generate_nsec()
264    }
265
266    /// This bot's own npub (bech32).
267    pub fn npub(&self) -> &str {
268        &self.npub
269    }
270
271    /// The underlying [`VectorCore`] facade, for operations not surfaced
272    /// ergonomically here (communities, `sync_dms`, custom rumors, etc.).
273    pub fn core(&self) -> VectorCore {
274        self.core
275    }
276
277    pub(crate) fn commands(&self) -> &commands::CommandRegistry {
278        &self.commands
279    }
280
281    /// This bot's invite policy (see [`InvitePolicy`]).
282    pub fn invite_policy(&self) -> &InvitePolicy {
283        &self.invite_policy
284    }
285
286    /// Parked Community invites awaiting a decision β€” each `{ community_id, name, inviter_npub }`.
287    /// (Auto-accepted invites are already gone; these are the ones held under
288    /// [`InvitePolicy::Manual`] or rejected by a whitelist.)
289    pub fn pending_invites(&self) -> Result<Vec<serde_json::Value>> {
290        self.core.list_pending_invites()
291    }
292
293    /// Accept a parked Community invite by id, then start receiving its channels.
294    pub async fn accept_invite(&self, community_id: &str) -> Result<serde_json::Value> {
295        let res = self.core.accept_pending_invite(community_id).await?;
296        // The realtime sub was built without this community; refresh so its channels flow in.
297        if let Some(client) = vector_core::state::nostr_client() {
298            vector_core::community::realtime::refresh_subscription(&client).await;
299        }
300        Ok(res)
301    }
302
303    /// Apply the invite policy to every currently-parked invite β€” auto-joining the ones it allows.
304    /// Called at `on_message` startup (so a restarted bot picks up invites received while it was
305    /// down); also safe to call manually. No-op under [`InvitePolicy::Manual`].
306    pub async fn process_pending_invites(&self) {
307        if matches!(*self.invite_policy, InvitePolicy::Manual) {
308            return;
309        }
310        let Ok(invites) = self.core.list_pending_invites() else { return };
311        for inv in invites {
312            let Some(cid) = inv.get("community_id").and_then(|c| c.as_str()) else { continue };
313            let inviter = inv.get("inviter_npub").and_then(|n| n.as_str());
314            if self.invite_policy.accepts(inviter) {
315                let _ = self.accept_invite(cid).await;
316            }
317        }
318    }
319
320    /// Apply [`invite_policy`](Self::invite_policy) to a just-arrived invite: auto-accept it when the
321    /// policy allows (and the inviter passes a whitelist), otherwise leave it parked. Invoked
322    /// automatically by the `on_message` listen loop; no-op under [`InvitePolicy::Manual`]. Exposed
323    /// so a custom [`listen_with`](Self::listen_with) handler can opt into the same policy.
324    pub async fn apply_invite_policy(&self, community_id: &str) {
325        if matches!(*self.invite_policy, InvitePolicy::Manual) {
326            return;
327        }
328        // Resolve the inviter from the parked record (needed for the whitelist check).
329        let inviter = self
330            .core
331            .list_pending_invites()
332            .ok()
333            .and_then(|invites| {
334                invites.into_iter().find_map(|i| {
335                    (i.get("community_id").and_then(|c| c.as_str()) == Some(community_id))
336                        .then(|| i.get("inviter_npub").and_then(|n| n.as_str()).map(String::from))
337                        .flatten()
338                })
339            });
340        if self.invite_policy.accepts(inviter.as_deref()) {
341            let _ = self.accept_invite(community_id).await;
342        }
343    }
344
345    /// A unified messaging handle for a chat or channel, **auto-detecting** whether `id` is a DM
346    /// (an `npub`) or a Community channel (a 64-char hex channel id). Send and receive work the
347    /// same way regardless β€” you never branch on the transport. Infallible; an invalid id surfaces
348    /// as an error when you actually send.
349    pub fn channel(&self, id: impl Into<String>) -> Channel {
350        let id = id.into();
351        let kind = channel_kind_for(&id);
352        Channel { core: self.core, id, kind }
353    }
354
355    /// An explicit DM handle for an `npub` (skips auto-detection).
356    pub fn dm(&self, npub: impl Into<String>) -> Channel {
357        Channel { core: self.core, id: npub.into(), kind: ChannelKind::Dm }
358    }
359
360    /// A [`Community`] handle by its community id, for management (members, invites, roles,
361    /// metadata). To *message* a community channel, use [`channel`](Self::channel) with the
362    /// channel id instead.
363    pub fn community(&self, community_id: impl Into<String>) -> Community {
364        Community { core: self.core, id: community_id.into() }
365    }
366
367    /// Every Community this bot is a member of.
368    pub async fn communities(&self) -> Vec<Community> {
369        self.core
370            .list_communities()
371            .await
372            .into_iter()
373            .filter_map(|v| {
374                v.get("community_id")
375                    .or_else(|| v.get("id"))
376                    .and_then(|i| i.as_str())
377                    .map(|id| self.community(id.to_string()))
378            })
379            .collect()
380    }
381
382    // ---- receiving ----
383
384    /// Register an async message handler and block, processing inbound DMs and
385    /// file attachments until the client disconnects. The handler is invoked
386    /// once per message with a clone of the bot (so it can reply) and an
387    /// [`IncomingMessage`]. A slow handler won't hold up other messages.
388    ///
389    /// ```no_run
390    /// # use vector_sdk::VectorBot;
391    /// # async fn run(bot: VectorBot) -> vector_sdk::Result<()> {
392    /// bot.on_message(|_bot, msg| async move {
393    ///     if msg.is_mine() { return; } // ignore our own echoes
394    ///     // `reply` works the same for DMs and Community channels.
395    ///     let _ = msg.reply(&format!("You said: {}", msg.text())).await;
396    /// }).await?;
397    /// # Ok(()) }
398    /// ```
399    pub async fn on_message<F, Fut>(&self, handler: F) -> Result<()>
400    where
401        F: Fn(VectorBot, IncomingMessage) -> Fut + Send + Sync + 'static,
402        Fut: Future<Output = ()> + Send + 'static,
403    {
404        self.prepare_listen().await;
405        let adapter = ClosureHandler {
406            bot: self.clone(),
407            handler: Arc::new(handler),
408        };
409        self.core.listen(Arc::new(adapter)).await
410    }
411
412    /// Register an async handler for **every** kind of inbound event β€” messages, reactions/edits,
413    /// deletes, member join/leave, typing, invites, and being removed β€” and block until disconnect.
414    /// Match on [`BotEvent`]; ignore the variants you don't care about. A superset of
415    /// [`on_message`](Self::on_message) (use that if you only want messages).
416    ///
417    /// ```no_run
418    /// # use vector_sdk::{VectorBot, BotEvent};
419    /// # async fn run(bot: VectorBot) -> vector_sdk::Result<()> {
420    /// bot.on_event(|bot, event| async move {
421    ///     match event {
422    ///         BotEvent::Message(msg) if !msg.is_mine() => { let _ = msg.reply("hi").await; }
423    ///         BotEvent::MemberJoin { channel_id, npub } => {
424    ///             let _ = bot.channel(channel_id).send(&format!("welcome {}!", &npub[..12])).await;
425    ///         }
426    ///         _ => {}
427    ///     }
428    /// }).await?;
429    /// # Ok(()) }
430    /// ```
431    pub async fn on_event<F, Fut>(&self, handler: F) -> Result<()>
432    where
433        F: Fn(VectorBot, BotEvent) -> Fut + Send + Sync + 'static,
434        Fut: Future<Output = ()> + Send + 'static,
435    {
436        self.prepare_listen().await;
437        let adapter = EventClosureHandler {
438            bot: self.clone(),
439            handler: Arc::new(handler),
440        };
441        self.core.listen(Arc::new(adapter)).await
442    }
443
444    /// Shared listen startup: catch up DMs FIRST so any invite delivered while offline is parked,
445    /// THEN apply the invite policy to everything parked (so a restarted private bot still auto-joins
446    /// communities it was invited to). Live invites are handled by the event adapters. Registered
447    /// slash commands publish their interface manifest here so pickers can discover them.
448    async fn prepare_listen(&self) {
449        let _ = self.core.sync_dms(None, &NoOpEventHandler).await;
450        self.process_pending_invites().await;
451        self.publish_interface_manifest().await;
452    }
453
454    /// Escape hatch: drive the receive loop with a custom
455    /// [`InboundEventHandler`] for full control over every event kind.
456    pub async fn listen_with(&self, handler: Arc<dyn InboundEventHandler>) -> Result<()> {
457        self.core.listen(handler).await
458    }
459
460    /// Backfill historical DMs via NIP-77 negentropy set reconciliation.
461    /// Returns `(events_processed, new_messages)`. Pass `Some(days)` to limit
462    /// the window, or `None` for a full sync.
463    pub async fn sync_dms(&self, since_days: Option<u64>) -> Result<(u32, u32)> {
464        self.core.sync_dms(since_days, &NoOpEventHandler).await
465    }
466
467    /// Catch up every Community this bot is in β€” refold consensus (re-foundings / rekeys / banlist /
468    /// metadata) and fetch recent messages into local state. Runs automatically inside
469    /// [`on_message`](Self::on_message)/`listen` on connect and periodically for outage resilience;
470    /// exposed for manual use (e.g. right after a known reconnect).
471    pub async fn sync_communities(&self) -> Result<()> {
472        self.core.sync_communities().await
473    }
474
475    // ---- profiles ----
476
477    /// Fetch a profile from relays and return the merged result. Returns `None`
478    /// if nothing could be resolved.
479    pub async fn fetch_profile(&self, npub: &str) -> Option<SlimProfile> {
480        self.core.load_profile(npub).await;
481        self.core.get_profile(npub).await
482    }
483
484    /// Read a profile already in local state without hitting the network.
485    pub async fn cached_profile(&self, npub: &str) -> Option<SlimProfile> {
486        self.core.get_profile(npub).await
487    }
488
489    /// Update this bot's own profile metadata (broadcasts a kind-0 event). The profile is always
490    /// tagged `bot: true` so clients can badge it as a bot β€” that's the whole point of the SDK. If
491    /// you're building a human client, use [`vector_core`]'s `update_profile` directly instead.
492    pub async fn update_profile(&self, name: &str, avatar: &str, banner: &str, about: &str) -> bool {
493        self.core.update_bot_profile(name, avatar, banner, about).await
494    }
495
496    /// Set this bot's status (kind-30315).
497    pub async fn set_status(&self, status: &str) -> bool {
498        self.core.update_status(status).await
499    }
500
501    /// Block a user (adds them to the mute list).
502    pub async fn block(&self, npub: &str) -> bool {
503        self.core.block_user(npub).await
504    }
505
506    /// Unblock a previously blocked user.
507    pub async fn unblock(&self, npub: &str) -> bool {
508        self.core.unblock_user(npub).await
509    }
510
511    /// Set a local-only nickname for a user (never broadcast).
512    pub async fn set_nickname(&self, npub: &str, nickname: &str) -> bool {
513        self.core.set_nickname(npub, nickname).await
514    }
515
516    /// List all blocked users.
517    pub async fn blocked_users(&self) -> Vec<SlimProfile> {
518        self.core.get_blocked_users().await
519    }
520
521    // ---- attachments ----
522
523    /// Download a received attachment and decrypt it to plaintext bytes (fetches the encrypted blob
524    /// from its Blossom URL, then AES-decrypts with the attachment's embedded key + nonce). Find
525    /// attachments on `msg.message.attachments`.
526    pub async fn download_attachment(&self, attachment: &Attachment) -> Result<Vec<u8>> {
527        self.core.download_attachment(attachment).await
528    }
529
530    /// Upload a local image (avatar, banner, …) to Blossom and return its public URL. Unlike
531    /// [`send_file`](Channel::send_file)'s encrypted attachments, this is uploaded in the clear so
532    /// other clients can fetch it directly β€” pass the URL to [`update_profile`](Self::update_profile).
533    pub async fn upload_image(&self, path: impl AsRef<std::path::Path>) -> Result<String> {
534        let path = path.as_ref().to_string_lossy().into_owned();
535        self.core.upload_public_image(&path).await
536    }
537
538    /// Download a received attachment and write the decrypted bytes to `path`. Returns the path.
539    pub async fn save_attachment(&self, attachment: &Attachment, path: impl Into<PathBuf>) -> Result<PathBuf> {
540        let path = path.into();
541        let bytes = self.core.download_attachment(attachment).await?;
542        std::fs::write(&path, bytes).map_err(VectorError::Io)?;
543        Ok(path)
544    }
545
546    // ---- lifecycle ----
547
548    /// Disconnect from relays and close the local database.
549    pub async fn logout(&self) {
550        self.core.logout().await
551    }
552}
553
554// ============================================================================
555// Builder
556// ============================================================================
557
558/// Builder for a [`VectorBot`]. Created via [`VectorBot::builder`].
559#[derive(Default)]
560pub struct VectorBotBuilder {
561    key: Option<String>,
562    password: Option<String>,
563    data_dir: Option<PathBuf>,
564    event_emitter: Option<Box<dyn EventEmitter>>,
565    invite_policy: Option<InvitePolicy>,
566    #[cfg(feature = "tor")]
567    tor: bool,
568    #[cfg(feature = "tor")]
569    tor_bridges: Vec<String>,
570}
571
572impl VectorBotBuilder {
573    /// Set the account key: an `nsec1…` secret key **or** a BIP-39 mnemonic
574    /// phrase. Equivalent to [`nsec`](Self::nsec) / [`mnemonic`](Self::mnemonic).
575    pub fn key(mut self, key: impl Into<String>) -> Self {
576        self.key = Some(key.into());
577        self
578    }
579
580    /// Set the account's `nsec1…` secret key.
581    pub fn nsec(self, nsec: impl Into<String>) -> Self {
582        self.key(nsec)
583    }
584
585    /// Set the account from a BIP-39 mnemonic seed phrase (NIP-06).
586    pub fn mnemonic(self, phrase: impl Into<String>) -> Self {
587        self.key(phrase)
588    }
589
590    /// Provide the password/PIN for an encrypted-at-rest account.
591    pub fn password(mut self, password: impl Into<String>) -> Self {
592        self.password = Some(password.into());
593        self
594    }
595
596    /// Set the Community invite policy explicitly (see [`InvitePolicy`]). Defaults to
597    /// [`InvitePolicy::Manual`].
598    pub fn invite_policy(mut self, policy: InvitePolicy) -> Self {
599        self.invite_policy = Some(policy);
600        self
601    }
602
603    /// Make this a **public** bot β€” auto-accept Community invites from anyone.
604    /// Shorthand for [`invite_policy(InvitePolicy::Public)`](Self::invite_policy).
605    pub fn public(self) -> Self {
606        self.invite_policy(InvitePolicy::Public)
607    }
608
609    /// Make this a **private** bot β€” auto-accept invites *only* from these pubkeys, ignoring all
610    /// others. Accepts `npub1…` or hex; each is normalized to bech32 (un-parseable entries are
611    /// dropped) so the whitelist always matches the inviter form the SDK compares against.
612    /// Shorthand for [`invite_policy(InvitePolicy::Whitelist(..))`](Self::invite_policy).
613    pub fn whitelist(self, npubs: impl IntoIterator<Item = impl Into<String>>) -> Self {
614        let normalized = npubs
615            .into_iter()
616            .filter_map(|n| {
617                let s = n.into();
618                nostr_sdk::PublicKey::parse(&s).ok().and_then(|pk| pk.to_bech32().ok())
619            })
620            .collect();
621        self.invite_policy(InvitePolicy::Whitelist(normalized))
622    }
623
624    /// Override the data directory (SQLite DB + per-account storage). Defaults
625    /// to a per-OS application directory.
626    pub fn data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
627        self.data_dir = Some(dir.into());
628        self
629    }
630
631    /// Plug in a custom [`EventEmitter`] to bridge core events into your app or
632    /// logs. Optional β€” defaults to a no-op.
633    pub fn event_emitter(mut self, emitter: Box<dyn EventEmitter>) -> Self {
634        self.event_emitter = Some(emitter);
635        self
636    }
637
638    /// Route **all** of this bot's traffic through embedded Tor. Requires the `tor` feature.
639    ///
640    /// Tor is started and bootstrapped during [`build`](Self::build) *before* the bot connects,
641    /// so the bot never touches the network in the clear. Bootstrapping can take several seconds.
642    #[cfg(feature = "tor")]
643    pub fn tor(mut self) -> Self {
644        self.tor = true;
645        self
646    }
647
648    /// Like [`tor`](Self::tor), but route through the given Tor **bridges** instead of public
649    /// entry relays β€” for networks where Tor itself is blocked. Each entry is a bridge line
650    /// (e.g. `"1.2.3.4:443 <fingerprint>"`). Implies [`tor`](Self::tor).
651    #[cfg(feature = "tor")]
652    pub fn tor_bridges(mut self, bridges: impl IntoIterator<Item = impl Into<String>>) -> Self {
653        self.tor = true;
654        self.tor_bridges = bridges.into_iter().map(Into::into).collect();
655        self
656    }
657
658    /// Initialize core, resolve the identity, log in, and connect to relays.
659    ///
660    /// If no key was supplied via [`nsec`](Self::nsec) / [`mnemonic`](Self::mnemonic), the bot loads
661    /// β€” or, on first run, **creates and persists** β€” an identity (`identity.nsec`) in its data
662    /// directory, so it keeps the same npub across restarts. An explicit key always takes precedence.
663    pub async fn build(self) -> Result<VectorBot> {
664        let data_dir = self.data_dir.unwrap_or_else(default_data_dir);
665        std::fs::create_dir_all(&data_dir).ok();
666
667        let core = VectorCore::init(CoreConfig {
668            data_dir: data_dir.clone(),
669            event_emitter: self.event_emitter,
670        })?;
671
672        // An explicit key wins; otherwise load β€” or, on first run, create β€” a persistent identity.
673        let (key, fresh_identity) = match self.key {
674            Some(key) => (key, None),
675            None => {
676                let (nsec, path, created) = load_or_create_identity(core, &data_dir)?;
677                (nsec, created.then_some(path))
678            }
679        };
680
681        // Bring Tor up BEFORE login connects: prime this account's DB with the Tor preference and
682        // start the service, so login's own relay connect already routes through Tor (no clear-net
683        // handshake). login re-reads the same DB setting, so the preference sticks.
684        #[cfg(feature = "tor")]
685        if self.tor {
686            use nostr_sdk::prelude::*;
687            let keys = if key.starts_with("nsec1") {
688                Keys::new(
689                    SecretKey::from_bech32(&key)
690                        .map_err(|e| VectorError::Other(format!("invalid nsec: {e}")))?,
691                )
692            } else {
693                Keys::from_mnemonic(&key, None)
694                    .map_err(|e| VectorError::Other(format!("invalid mnemonic: {e}")))?
695            };
696            let npub = keys.public_key().to_bech32().map_err(|e| VectorError::Other(e.to_string()))?;
697
698            vector_core::db::set_current_account(npub.clone()).map_err(VectorError::Other)?;
699            vector_core::db::init_database(&npub).map_err(VectorError::Other)?;
700            vector_core::db::settings::set_sql_setting("tor_enabled".to_string(), "1".to_string())
701                .map_err(VectorError::Other)?;
702            vector_core::tor::set_tor_enabled_pref(true);
703
704            let tor_dir = vector_core::db::account_dir(&npub).map_err(VectorError::Other)?.join("tor");
705            let (state_dir, cache_dir) = (tor_dir.join("state"), tor_dir.join("cache"));
706            std::fs::create_dir_all(&state_dir).ok();
707            std::fs::create_dir_all(&cache_dir).ok();
708
709            // Fail closed: blackhole the shared client until bootstrap finishes, then start + rebuild.
710            vector_core::net::rebuild_shared_http_client().map_err(VectorError::Other)?;
711            vector_core::tor::TorService::start(state_dir, cache_dir, &self.tor_bridges)
712                .await
713                .map_err(VectorError::Other)?;
714            vector_core::net::rebuild_shared_http_client().map_err(VectorError::Other)?;
715        }
716
717        let result = core.login(&key, self.password.as_deref()).await?;
718
719        // One-time provisioning notice β€” the only thing the SDK writes to stderr.
720        if let Some(path) = fresh_identity {
721            eprintln!(
722                "[vector-sdk] Created a new bot identity {} (stored at {}). \
723                 Back it up β€” that file is the bot.",
724                result.npub,
725                path.display()
726            );
727        }
728
729        Ok(VectorBot {
730            core,
731            npub: result.npub,
732            invite_policy: Arc::new(self.invite_policy.unwrap_or(InvitePolicy::Manual)),
733            commands: Arc::new(Default::default()),
734        })
735    }
736}
737
738// ============================================================================
739// Channel β€” unified DM + Community messaging handle
740// ============================================================================
741
742/// Whether a [`Channel`] targets a direct message or a Community channel.
743#[derive(Clone, Copy, Debug, PartialEq, Eq)]
744pub enum ChannelKind {
745    /// A 1:1 direct message, addressed by the recipient's `npub`.
746    Dm,
747    /// A Community channel, addressed by its channel id.
748    Community,
749}
750
751/// A unified handle for a chat or channel β€” **a DM and a Community channel behave the same**. Every
752/// method routes to the right transport under the hood, so a bot author never branches on DM-vs-
753/// channel. Obtained from [`VectorBot::channel`] / [`dm`](VectorBot::dm) /
754/// [`community`](VectorBot::community), or [`IncomingMessage::channel`].
755#[derive(Clone)]
756pub struct Channel {
757    core: VectorCore,
758    id: String,
759    kind: ChannelKind,
760}
761
762impl Channel {
763    /// The id of this chat or channel β€” an `npub` for a DM, a channel id for a Community channel.
764    pub fn id(&self) -> &str {
765        &self.id
766    }
767
768    /// Whether this is a DM or a Community channel.
769    pub fn kind(&self) -> ChannelKind {
770        self.kind
771    }
772
773    /// `true` for a direct message.
774    pub fn is_dm(&self) -> bool {
775        matches!(self.kind, ChannelKind::Dm)
776    }
777
778    /// `true` for a Community channel.
779    pub fn is_community(&self) -> bool {
780        matches!(self.kind, ChannelKind::Community)
781    }
782
783    /// Send a text message. Returns the new message's event id.
784    pub async fn send(&self, text: &str) -> Result<String> {
785        match self.kind {
786            ChannelKind::Dm => self
787                .core
788                .send_dm(&self.id, text)
789                .await
790                .map(|r| r.event_id.unwrap_or(r.pending_id)),
791            ChannelKind::Community => self.core.send_community_message(&self.id, text, None).await,
792        }
793    }
794
795    /// Send a text message as a **threaded reply** to `replied_to` (an existing message's id).
796    /// Works for DMs and Community channels. Returns the new message's event id.
797    pub async fn reply(&self, replied_to: &str, text: &str) -> Result<String> {
798        match self.kind {
799            ChannelKind::Dm => self
800                .core
801                .send_dm_reply(&self.id, replied_to, text)
802                .await
803                .map(|r| r.event_id.unwrap_or(r.pending_id)),
804            ChannelKind::Community => {
805                self.core.send_community_message(&self.id, text, Some(replied_to)).await
806            }
807        }
808    }
809
810    /// React to a message with a unicode emoji (e.g. `"πŸ‘"`).
811    pub async fn react(&self, message_id: &str, emoji: &str) -> Result<()> {
812        match self.kind {
813            ChannelKind::Dm => self.core.send_reaction(&self.id, message_id, emoji, None).await.map(|_| ()),
814            ChannelKind::Community => self.core.send_community_reaction(&self.id, message_id, emoji, None).await,
815        }
816    }
817
818    /// React with a custom NIP-30 pack emoji: a `:shortcode:` plus its image URL.
819    pub async fn react_custom(&self, message_id: &str, shortcode_emoji: &str, image_url: &str) -> Result<()> {
820        match self.kind {
821            ChannelKind::Dm => self.core.send_reaction(&self.id, message_id, shortcode_emoji, Some(image_url)).await.map(|_| ()),
822            ChannelKind::Community => self.core.send_community_reaction(&self.id, message_id, shortcode_emoji, Some(image_url)).await,
823        }
824    }
825
826    /// Send an ephemeral typing indicator. Useful while the bot is "thinking".
827    pub async fn typing(&self) -> Result<()> {
828        match self.kind {
829            ChannelKind::Dm => self.core.send_typing(&self.id).await,
830            ChannelKind::Community => self.core.send_community_typing(&self.id).await,
831        }
832    }
833
834    /// Edit a message you previously sent.
835    pub async fn edit(&self, message_id: &str, new_content: &str) -> Result<()> {
836        match self.kind {
837            ChannelKind::Dm => self.core.edit_dm(&self.id, message_id, new_content).await.map(|_| ()),
838            ChannelKind::Community => self.core.edit_community_message(&self.id, message_id, new_content).await,
839        }
840    }
841
842    /// Delete a message you sent.
843    pub async fn delete(&self, message_id: &str) -> Result<()> {
844        match self.kind {
845            ChannelKind::Dm => self.core.delete_dm(message_id).await.map(|_| ()),
846            ChannelKind::Community => self.core.delete_community_message(message_id).await,
847        }
848    }
849
850    /// Send a file from disk as an encrypted attachment β€” works for DMs and Community channels.
851    pub async fn send_file(&self, path: impl AsRef<std::path::Path>) -> Result<String> {
852        let path = path.as_ref().to_string_lossy().into_owned();
853        match self.kind {
854            ChannelKind::Dm => self
855                .core
856                .send_file(&self.id, &path)
857                .await
858                .map(|r| r.event_id.unwrap_or(r.pending_id)),
859            ChannelKind::Community => self.core.send_community_file(&self.id, &path).await,
860        }
861    }
862}
863
864// ============================================================================
865// Inbound message handling
866// ============================================================================
867
868/// An inbound message delivered to an [`VectorBot::on_message`] handler. The same handler receives
869/// both DMs and Community channel messages β€” use [`reply`](Self::reply) / [`channel`](Self::channel)
870/// to respond uniformly without caring which it is.
871#[derive(Clone, Debug)]
872pub struct IncomingMessage {
873    /// The chat or channel id. For a DM this is the sender's npub; for a Community message it's the
874    /// channel id. Prefer [`reply`](Self::reply) / [`channel`](Self::channel) over using it directly.
875    pub chat_id: String,
876    /// `true` when this message arrived in a Community channel rather than a DM.
877    pub is_group: bool,
878    /// `true` when this message carries a file attachment.
879    pub is_file: bool,
880    /// The full message: content, attachments, reactions, timestamps, and the
881    /// `mine` flag (true for the bot's own messages).
882    pub message: Message,
883}
884
885impl IncomingMessage {
886    /// The [`Channel`] this message arrived on β€” reply, react, or type into it uniformly,
887    /// regardless of whether it's a DM or a Community channel.
888    pub fn channel(&self) -> Channel {
889        Channel {
890            core: VectorCore,
891            id: self.chat_id.clone(),
892            kind: if self.is_group { ChannelKind::Community } else { ChannelKind::Dm },
893        }
894    }
895
896    /// Respond as a **threaded reply** to this message β€” the response references it, so clients
897    /// render it as a reply. Works identically for DMs and Community channels. (For a plain,
898    /// non-threaded response in the same chat or channel, use `msg.channel().send(...)`.)
899    pub async fn reply(&self, text: &str) -> Result<String> {
900        self.channel().reply(&self.message.id, text).await
901    }
902
903    /// React to *this* message with an emoji.
904    pub async fn react(&self, emoji: &str) -> Result<()> {
905        self.channel().react(&self.message.id, emoji).await
906    }
907
908    /// The [`Community`] this message belongs to β€” `None` for DMs. Use it for community-level
909    /// management (invites, roles, metadata).
910    pub fn community(&self) -> Option<Community> {
911        if !self.is_group {
912            return None;
913        }
914        let community_id = vector_core::db::community::community_id_for_channel(&self.chat_id)
915            .ok()
916            .flatten()?;
917        Some(Community { core: VectorCore, id: community_id })
918    }
919
920    /// The sender as a [`Member`] of this community β€” `None` for DMs or if the sender is unknown.
921    /// Act on them directly: `msg.member()?.kick().await`, `.ban()`, `.grant_admin()`, etc.
922    pub fn member(&self) -> Option<Member> {
923        let community = self.community()?;
924        let npub = self.message.npub.clone()?;
925        Some(Member { core: VectorCore, community_id: community.id, npub })
926    }
927
928    /// The message text.
929    pub fn text(&self) -> &str {
930        &self.message.content
931    }
932
933    /// `true` if this is the bot's own message (e.g. its own echo).
934    pub fn is_mine(&self) -> bool {
935        self.message.mine
936    }
937}
938
939// ============================================================================
940// Community + Member β€” object model for community management
941// ============================================================================
942
943/// A handle to a Community for management β€” members, invites, roles, metadata. Obtained from
944/// [`VectorBot::community`], [`VectorBot::communities`], or [`IncomingMessage::community`]. To
945/// *message* a channel within it, use a [`Channel`] (`bot.channel(channel_id)`).
946#[derive(Clone)]
947pub struct Community {
948    core: VectorCore,
949    id: String,
950}
951
952impl Community {
953    /// The community id.
954    pub fn id(&self) -> &str {
955        &self.id
956    }
957
958    /// A handle to a member of this community by npub β€” act on them directly.
959    pub fn member(&self, npub: impl Into<String>) -> Member {
960        Member { core: self.core, community_id: self.id.clone(), npub: npub.into() }
961    }
962
963    /// Observed members (best-effort, from recent activity).
964    pub async fn members(&self) -> Vec<Member> {
965        self.core
966            .get_community_members(&self.id)
967            .await
968            .into_iter()
969            .filter_map(|v| v.get("npub").and_then(|n| n.as_str()).map(|n| self.member(n.to_string())))
970            .collect()
971    }
972
973    /// Invite an npub via a gift-wrapped private invite (requires the create-invite permission).
974    pub async fn invite(&self, npub: &str) -> Result<()> {
975        self.core.invite_to_community(&self.id, npub).await.map(|_| ())
976    }
977
978    /// Mint a public invite link for this community.
979    pub async fn create_invite(&self) -> Result<String> {
980        self.core.create_public_invite(&self.id).await
981    }
982
983    /// Update the community's name and/or description.
984    pub async fn edit(&self, name: Option<&str>, description: Option<&str>) -> Result<()> {
985        self.core.edit_community_metadata(&self.id, name, description).await
986    }
987
988    /// Leave this community.
989    pub async fn leave(&self) -> Result<()> {
990        self.core.leave_community(&self.id).await
991    }
992
993    /// Dissolve this community (owner only, irreversible).
994    pub async fn dissolve(&self) -> Result<()> {
995        self.core.dissolve_community(&self.id).await
996    }
997
998    /// Your own role-based capabilities here (JSON flags: manage_*, create_invite, kick, ban, …).
999    pub fn capabilities(&self) -> Result<serde_json::Value> {
1000        self.core.community_capabilities(&self.id)
1001    }
1002
1003    /// The owner + admin npubs (`{ owner, admins: [...] }`).
1004    pub fn roles(&self) -> Result<serde_json::Value> {
1005        self.core.community_roles(&self.id)
1006    }
1007}
1008
1009/// A handle to a member of a community β€” act on them directly. Obtained from
1010/// [`Community::member`] or [`IncomingMessage::member`].
1011#[derive(Clone)]
1012pub struct Member {
1013    core: VectorCore,
1014    community_id: String,
1015    npub: String,
1016}
1017
1018impl Member {
1019    /// This member's npub.
1020    pub fn npub(&self) -> &str {
1021        &self.npub
1022    }
1023
1024    /// The id of the community this handle is scoped to.
1025    pub fn community_id(&self) -> &str {
1026        &self.community_id
1027    }
1028
1029    /// Cooperatively kick them (they can rejoin). Requires KICK + outranking them.
1030    pub async fn kick(&self) -> Result<()> {
1031        self.core.kick_member(&self.community_id, &self.npub).await
1032    }
1033
1034    /// Ban them (terminal; in a private community this triggers a read-cut rekey). Requires BAN.
1035    pub async fn ban(&self) -> Result<()> {
1036        self.core.set_member_banned(&self.community_id, &self.npub, true).await
1037    }
1038
1039    /// Lift a ban.
1040    pub async fn unban(&self) -> Result<()> {
1041        self.core.set_member_banned(&self.community_id, &self.npub, false).await
1042    }
1043
1044    /// Grant them the @admin role (requires MANAGE_ROLES).
1045    pub async fn grant_admin(&self) -> Result<()> {
1046        self.core.grant_admin(&self.community_id, &self.npub).await
1047    }
1048
1049    /// Revoke their @admin role.
1050    pub async fn revoke_admin(&self) -> Result<()> {
1051        self.core.revoke_admin(&self.community_id, &self.npub).await
1052    }
1053
1054    /// Fetch this member's profile.
1055    pub async fn profile(&self) -> Option<SlimProfile> {
1056        self.core.load_profile(&self.npub).await;
1057        self.core.get_profile(&self.npub).await
1058    }
1059
1060    /// Whether this member is the community owner.
1061    pub fn is_owner(&self) -> bool {
1062        self.core
1063            .community_roles(&self.community_id)
1064            .ok()
1065            .and_then(|r| r.get("owner").and_then(|o| o.as_str()).map(|o| o == self.npub))
1066            .unwrap_or(false)
1067    }
1068
1069    /// Whether this member is an admin (the owner counts as admin).
1070    pub fn is_admin(&self) -> bool {
1071        let Ok(roles) = self.core.community_roles(&self.community_id) else { return false };
1072        let owner = roles.get("owner").and_then(|o| o.as_str()) == Some(self.npub.as_str());
1073        let admin = roles
1074            .get("admins")
1075            .and_then(|a| a.as_array())
1076            .map(|arr| arr.iter().any(|n| n.as_str() == Some(self.npub.as_str())))
1077            .unwrap_or(false);
1078        owner || admin
1079    }
1080}
1081
1082/// Adapts a user closure into an [`InboundEventHandler`].
1083struct ClosureHandler<F> {
1084    bot: VectorBot,
1085    handler: Arc<F>,
1086}
1087
1088impl<F, Fut> ClosureHandler<F>
1089where
1090    F: Fn(VectorBot, IncomingMessage) -> Fut + Send + Sync + 'static,
1091    Fut: Future<Output = ()> + Send + 'static,
1092{
1093    fn dispatch(&self, chat_id: &str, msg: &Message, is_file: bool, is_group: bool) {
1094        let handler = self.handler.clone();
1095        let bot = self.bot.clone();
1096        let incoming = IncomingMessage {
1097            chat_id: chat_id.to_string(),
1098            is_group,
1099            is_file,
1100            message: msg.clone(),
1101        };
1102        // A registered slash command consumes the message (its handler runs
1103        // instead of the chat handler β€” commands aren't chat).
1104        if bot.try_command(&incoming) {
1105            return;
1106        }
1107        tokio::spawn(async move {
1108            handler(bot, incoming).await;
1109        });
1110    }
1111}
1112
1113impl<F, Fut> InboundEventHandler for ClosureHandler<F>
1114where
1115    F: Fn(VectorBot, IncomingMessage) -> Fut + Send + Sync + 'static,
1116    Fut: Future<Output = ()> + Send + 'static,
1117{
1118    fn on_dm_received(&self, chat_id: &str, msg: &Message, _is_new: bool) {
1119        self.dispatch(chat_id, msg, false, false);
1120    }
1121
1122    fn on_file_received(&self, chat_id: &str, msg: &Message, _is_new: bool) {
1123        self.dispatch(chat_id, msg, true, false);
1124    }
1125
1126    fn on_community_message(&self, chat_id: &str, msg: &Message, _is_new: bool) {
1127        // Community has a single message hook, so derive is_file from the payload (DMs split it
1128        // across on_dm_received / on_file_received instead).
1129        self.dispatch(chat_id, msg, !msg.attachments.is_empty(), true);
1130    }
1131
1132    fn on_community_invite(&self, community_id: &str) {
1133        // Apply the bot's InvitePolicy β€” auto-accept (public / whitelisted inviter) or leave parked.
1134        let bot = self.bot.clone();
1135        let community_id = community_id.to_string();
1136        tokio::spawn(async move {
1137            bot.apply_invite_policy(&community_id).await;
1138        });
1139    }
1140}
1141
1142// ============================================================================
1143// BotEvent β€” the full inbound-event stream for `on_event`
1144// ============================================================================
1145
1146/// Every kind of inbound event a bot can observe. Delivered to [`VectorBot::on_event`]. DMs and
1147/// Community channels are unified: `chat_id` is the sender's npub for a DM, the channel id for a
1148/// Community message.
1149#[derive(Clone, Debug)]
1150pub enum BotEvent {
1151    /// A new message (DM or Community channel).
1152    Message(IncomingMessage),
1153    /// A reaction or edit landed on an existing message; `message` is the updated view (inspect
1154    /// `.reactions` / `.content`, keyed by `message.id`).
1155    MessageUpdate { chat_id: String, message: Message },
1156    /// A message was deleted (cooperative delete / moderation tombstone).
1157    Delete { chat_id: String, message_id: String },
1158    /// A member joined a Community channel.
1159    MemberJoin { channel_id: String, npub: String },
1160    /// A member left (or was kicked from) a Community channel.
1161    MemberLeave { channel_id: String, npub: String },
1162    /// A member is typing in a Community channel; `until` is the unix-secs the indicator expires.
1163    Typing { chat_id: String, npub: String, until: u64 },
1164    /// A Community invite arrived. Already auto-handled per [`InvitePolicy`]; surfaced for visibility
1165    /// (and for `Manual` policy, so you can decide via [`VectorBot::accept_invite`]).
1166    Invite { community_id: String },
1167    /// This bot was removed from a Community (kicked / banned / a leave authored on another device).
1168    Removed { community_id: String },
1169}
1170
1171/// Adapts a user `on_event` closure into an [`InboundEventHandler`], mapping every hook to a [`BotEvent`].
1172struct EventClosureHandler<F> {
1173    bot: VectorBot,
1174    handler: Arc<F>,
1175}
1176
1177impl<F, Fut> EventClosureHandler<F>
1178where
1179    F: Fn(VectorBot, BotEvent) -> Fut + Send + Sync + 'static,
1180    Fut: Future<Output = ()> + Send + 'static,
1181{
1182    fn emit(&self, event: BotEvent) {
1183        let handler = self.handler.clone();
1184        let bot = self.bot.clone();
1185        tokio::spawn(async move {
1186            handler(bot, event).await;
1187        });
1188    }
1189
1190    fn message(&self, chat_id: &str, msg: &Message, is_group: bool, is_file: bool) {
1191        let incoming = IncomingMessage {
1192            chat_id: chat_id.to_string(),
1193            is_group,
1194            is_file,
1195            message: msg.clone(),
1196        };
1197        // A registered slash command consumes the message (its handler runs
1198        // instead of the event stream β€” commands aren't chat).
1199        if self.bot.try_command(&incoming) {
1200            return;
1201        }
1202        self.emit(BotEvent::Message(incoming));
1203    }
1204}
1205
1206impl<F, Fut> InboundEventHandler for EventClosureHandler<F>
1207where
1208    F: Fn(VectorBot, BotEvent) -> Fut + Send + Sync + 'static,
1209    Fut: Future<Output = ()> + Send + 'static,
1210{
1211    fn on_dm_received(&self, chat_id: &str, msg: &Message, _is_new: bool) {
1212        self.message(chat_id, msg, false, false);
1213    }
1214    fn on_file_received(&self, chat_id: &str, msg: &Message, _is_new: bool) {
1215        self.message(chat_id, msg, false, true);
1216    }
1217    fn on_community_message(&self, chat_id: &str, msg: &Message, _is_new: bool) {
1218        self.message(chat_id, msg, true, !msg.attachments.is_empty());
1219    }
1220    fn on_reaction_received(&self, chat_id: &str, msg: &Message) {
1221        self.emit(BotEvent::MessageUpdate { chat_id: chat_id.to_string(), message: msg.clone() });
1222    }
1223    fn on_community_update(&self, chat_id: &str, _target_id: &str, msg: &Message) {
1224        self.emit(BotEvent::MessageUpdate { chat_id: chat_id.to_string(), message: msg.clone() });
1225    }
1226    fn on_message_deleted(&self, chat_id: &str, message_id: &str) {
1227        self.emit(BotEvent::Delete { chat_id: chat_id.to_string(), message_id: message_id.to_string() });
1228    }
1229    fn on_community_removed(&self, chat_id: &str, target_id: &str) {
1230        self.emit(BotEvent::Delete { chat_id: chat_id.to_string(), message_id: target_id.to_string() });
1231    }
1232    fn on_community_presence(
1233        &self,
1234        chat_id: &str,
1235        npub: &str,
1236        joined: bool,
1237        _event_id: &str,
1238        _created_at: u64,
1239        _invited_by: Option<&str>,
1240        _invited_label: Option<&str>,
1241    ) {
1242        let (channel_id, npub) = (chat_id.to_string(), npub.to_string());
1243        self.emit(if joined {
1244            BotEvent::MemberJoin { channel_id, npub }
1245        } else {
1246            BotEvent::MemberLeave { channel_id, npub }
1247        });
1248    }
1249    fn on_community_typing(&self, chat_id: &str, npub: &str, until: u64) {
1250        self.emit(BotEvent::Typing { chat_id: chat_id.to_string(), npub: npub.to_string(), until });
1251    }
1252    fn on_community_self_removed(&self, community_id: &str) {
1253        self.emit(BotEvent::Removed { community_id: community_id.to_string() });
1254    }
1255    fn on_community_invite(&self, community_id: &str) {
1256        // Auto-handle per policy (same as on_message), AND surface the event for visibility.
1257        let bot = self.bot.clone();
1258        let cid = community_id.to_string();
1259        tokio::spawn(async move {
1260            bot.apply_invite_policy(&cid).await;
1261        });
1262        self.emit(BotEvent::Invite { community_id: community_id.to_string() });
1263    }
1264}
1265
1266// ============================================================================
1267// Helpers
1268// ============================================================================
1269
1270/// Classify an id: a valid bech32 `npub` is a DM, anything else (a 64-char hex channel
1271/// id) is a Community channel.
1272fn channel_kind_for(id: &str) -> ChannelKind {
1273    if nostr_sdk::PublicKey::from_bech32(id).is_ok() {
1274        ChannelKind::Dm
1275    } else {
1276        ChannelKind::Community
1277    }
1278}
1279
1280/// Load the bot's persistent identity from `<data_dir>/identity.nsec`, creating and storing a fresh
1281/// one on first run. Returns `(nsec, path, created)` where `created` is true only on first run.
1282fn load_or_create_identity(core: VectorCore, data_dir: &std::path::Path) -> Result<(String, PathBuf, bool)> {
1283    let path = data_dir.join("identity.nsec");
1284    if let Ok(contents) = std::fs::read_to_string(&path) {
1285        let nsec = contents.trim();
1286        if !nsec.is_empty() {
1287            return Ok((nsec.to_string(), path, false));
1288        }
1289    }
1290    let nsec = core.generate_nsec()?;
1291    std::fs::write(&path, &nsec).map_err(VectorError::Io)?;
1292    restrict_to_owner(&path);
1293    Ok((nsec, path, true))
1294}
1295
1296/// Best-effort tighten of the identity file to owner-only read/write (no-op off unix).
1297#[cfg(unix)]
1298fn restrict_to_owner(path: &std::path::Path) {
1299    use std::os::unix::fs::PermissionsExt;
1300    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
1301}
1302#[cfg(not(unix))]
1303fn restrict_to_owner(_path: &std::path::Path) {}
1304
1305/// A per-OS default data directory for a bot's storage.
1306fn default_data_dir() -> PathBuf {
1307    #[cfg(target_os = "macos")]
1308    {
1309        if let Ok(home) = std::env::var("HOME") {
1310            return PathBuf::from(home).join("Library/Application Support/io.vectorapp/sdk");
1311        }
1312    }
1313    #[cfg(target_os = "linux")]
1314    {
1315        if let Ok(data) = std::env::var("XDG_DATA_HOME") {
1316            return PathBuf::from(data).join("io.vectorapp/sdk");
1317        }
1318        if let Ok(home) = std::env::var("HOME") {
1319            return PathBuf::from(home).join(".local/share/io.vectorapp/sdk");
1320        }
1321    }
1322    #[cfg(target_os = "windows")]
1323    {
1324        if let Ok(appdata) = std::env::var("APPDATA") {
1325            return PathBuf::from(appdata).join("io.vectorapp/sdk");
1326        }
1327    }
1328    PathBuf::from("vector-sdk-data")
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333    use super::*;
1334    use nostr_sdk::Keys;
1335
1336    #[test]
1337    fn invite_policy_matrix() {
1338        let a = Keys::generate().public_key().to_bech32().unwrap();
1339        let b = Keys::generate().public_key().to_bech32().unwrap();
1340
1341        // Manual never auto-accepts.
1342        assert!(!InvitePolicy::Manual.accepts(Some(&a)));
1343        assert!(!InvitePolicy::Manual.accepts(None));
1344
1345        // Public accepts anyone (even an unknown/absent inviter).
1346        assert!(InvitePolicy::Public.accepts(Some(&a)));
1347        assert!(InvitePolicy::Public.accepts(None));
1348
1349        // Whitelist accepts ONLY listed inviters, and never a missing one.
1350        let wl = InvitePolicy::Whitelist(vec![a.clone()]);
1351        assert!(wl.accepts(Some(&a)), "whitelisted inviter must be accepted");
1352        assert!(!wl.accepts(Some(&b)), "non-whitelisted inviter must be rejected");
1353        assert!(!wl.accepts(None), "missing inviter must be rejected under whitelist");
1354    }
1355
1356    #[test]
1357    fn channel_kind_auto_detection() {
1358        // A valid bech32 npub β†’ DM.
1359        let npub = Keys::generate().public_key().to_bech32().unwrap();
1360        assert_eq!(channel_kind_for(&npub), ChannelKind::Dm);
1361        // A 64-char hex channel id (and a raw-hex pubkey) β†’ Community (not bech32).
1362        assert_eq!(channel_kind_for(&"a".repeat(64)), ChannelKind::Community);
1363        assert_eq!(channel_kind_for(&Keys::generate().public_key().to_hex()), ChannelKind::Community);
1364    }
1365}