vector_sdk 0.5.0

Ergonomic Rust SDK for building Vector bots and clients on top of vector-core.
Documentation

Vector SDK

Build a bot for Vector — a private, encrypted messenger — in about a dozen lines of Rust. Your bot sends and receives messages, files, and reactions, joins communities, and rides out network drops, without you ever touching the protocol or encryption underneath.

Quick start

[dependencies]
vector_sdk = "0.3"
tokio = { version = "1", features = ["full"] }
use vector_sdk::VectorBot;

#[tokio::main]
async fn main() -> vector_sdk::Result<()> {
    let bot = VectorBot::builder()
        .nsec("nsec1...")          // the bot's key — or omit it and one is created for you
        .build()
        .await?;

    println!("Online as {}", bot.npub());

    // Reply to every message the bot receives.
    bot.on_message(|_bot, msg| async move {
        if msg.is_mine() { return; }
        let _ = msg.reply(&format!("You said: {}", msg.text())).await;
    }).await?;

    Ok(())
}

One API, everywhere

Your bot sends and receives through a Channel — a direct-message chat or a community channel, handled identically. You never branch on "is this a DM or a community?"; you just send and reply.

// `msg` could be from a DM or a community channel — same code either way:
msg.reply("got it").await?;        // reply in the same chat or channel
msg.react("👍").await?;            // react to it
msg.channel().typing().await?;     // show a typing indicator

// Or message a chat or channel directly by its id:
bot.channel(id).send("hi").await?;

What your bot can do

Messaging — on any chat or channel (bot.channel(id), bot.dm(npub), or msg.channel()):

Method What it does
channel.send(text) Send a message
channel.reply(msg_id, text) Reply to a specific message (threaded)
channel.react(msg_id, "👍") React with an emoji
channel.react_custom(msg_id, ":code:", url) React with a custom image emoji
channel.typing() Show a typing indicator
channel.edit(msg_id, text) / channel.delete(msg_id) Edit / delete a message the bot sent
channel.send_file(path) Send a file
bot.download_attachment(&att) / bot.save_attachment(&att, path) Get a received file

On an incoming message: msg.reply(text) answers it, msg.react(emoji) reacts to it, and any received files are on msg.message.attachments.

Communities — when a message comes from a community, you get the sender as a member you can act on directly:

if let Some(member) = msg.member() {
    member.kick().await?;          // or .ban() / .unban()
    member.grant_admin().await?;   // or .revoke_admin()
    if member.is_admin() { /* ... */ }
}

// Or manage a community directly:
let community = bot.community(community_id);  // also: msg.community(), bot.communities()
community.invite("npub1...").await?;
let link = community.create_invite().await?;
for m in community.members().await { /* ... */ }

Joining communities — to be useful in a community, a bot has to accept invites. Choose how:

VectorBot::builder().nsec(key).public().build().await?;                   // accept from anyone
VectorBot::builder().nsec(key).whitelist(["npub1owner…"]).build().await?; // only these accounts

By default, invites wait for you to handle them (bot.pending_invites() / bot.accept_invite(id)). Auto-accept also picks up invites that arrived while the bot was offline, so a restarted bot still joins.

Commands — don't parse message text by hand. Declare a slash command with typed arguments and the SDK publishes a manifest for it, so every Vector client renders a / picker with your command and a field per argument — a dropdown for a choice, a member picker for a user, a number field for an int — and validates the input before it's sent. Your handler gets the arguments already parsed and type-checked:

bot.command("weather", "Current conditions for a city")
    .string("city", "Which city", true)              // required free text
    .choice("units", "Temperature units", ["c", "f"], false)  // optional dropdown
    .run(|ctx| async move {
        let city = ctx.str("city").unwrap_or_default();
        let units = ctx.str("units").unwrap_or("c");
        ctx.reply(format!("Weather in {city}{})…", units.to_uppercase())).await.ok();
    });

Argument types: .string(), .int(), .number(), .flag() (bool), .user() (an npub), and .choice(name, desc, options, required); read them off ctx with ctx.str/int/number/flag(name). A matched command runs its handler and never reaches on_message, so commands and free-form chat live side by side. The manifest publishes automatically when the bot starts listening. See slash_command_bot for a full example.

Receivingbot.on_message(handler) runs your handler for every incoming message (DM or community); a slow handler won't hold up the others.

For more than messages, bot.on_event(|bot, event|) gives you the full stream — match the parts you care about:

bot.on_event(|bot, event| async move {
    match event {
        BotEvent::Message(msg) if !msg.is_mine() => { msg.reply("hi").await.ok(); }
        BotEvent::MemberJoin { channel_id, npub } => {
            bot.channel(channel_id).send("welcome!").await.ok();
        }
        BotEvent::MessageUpdate { .. } => { /* a reaction or edit landed */ }
        _ => {}
    }
}).await?;

BotEvent covers messages, reactions/edits, deletes, members joining or leaving, typing, invites, and the bot being removed.

Staying connected — if the bot loses its connection, it reconnects on its own and catches up on what it missed. Your handler fires for messages that arrive while the bot is running; to read older history, use bot.core().get_messages(...).

Profilesbot.fetch_profile(npub), bot.update_profile(...), bot.set_status(...), bot.block(npub) / bot.unblock(npub), bot.set_nickname(...).

Going deeperbot.core() exposes the full engine for anything not surfaced here (creating communities, reading history, and lower-level controls).

Examples

Runnable, self-contained bots live in examples/ — each shows off one thing. Every one needs VECTOR_NSEC (the bot's key); a few take extra env vars.

Example What it shows
echo_bot The minimal hello-world — replies to every message.
slash_command_bot A /command router: /ping, /echo, /roll, /help.
ai_bot An LLM chatbot: typing indicator, threaded replies, per-chat history.
moderation_bot Welcomes new members and auto-bans on a word filter.
whitelist_bot A private bot that only joins communities it trusts.
file_bot Sends one file, then exits.
save_files_bot Saves every received file to disk.
# Echo bot — replies to every message
VECTOR_NSEC=nsec1... cargo run --example echo_bot

# AI bot — wire any OpenAI-compatible endpoint to your chats
OPENAI_API_KEY=sk-... VECTOR_NSEC=nsec1... cargo run --example ai_bot

Accounts & keys

  • No keybuild() creates an identity on first run and reuses it every run after. Perfect for a first bot. Running several keyless bots? Give each its own .data_dir(...).
  • .nsec("nsec1...") — an existing key.
  • .mnemonic("twelve words ...") — a 12-word seed phrase.
  • .password("...") — only for keys that are encrypted at rest.
  • VectorBot::generate_nsec() — mint a fresh key yourself.

A keyless bot's identity is stable across restarts, so it keeps its chats and community memberships. Storage defaults to a per-OS application directory; override it with .data_dir(path).

One bot per process

A bot owns the process while it runs, so run one bot per process. To run several bots, run several processes.

Optional: Tor

To route the bot through Tor, enable the tor feature and call .tor() on the builder (the feature alone only compiles Tor in; .tor() turns it on):

vector_sdk = { version = "0.3", features = ["tor"] }
let bot = VectorBot::builder().nsec(key).tor().build().await?;
// .tor_bridges(["1.2.3.4:443 <fingerprint>"]) for networks where Tor is blocked

Tor is bootstrapped during build() before the bot connects, so it never touches the network in the clear. The feature is off by default, keeping the dependency tree light.

License

MIT.