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}