#[macro_use]
mod macros;
pub mod error;
pub mod traits;
use nostr_sdk::prelude::ToBech32;
pub mod types;
pub mod profile;
pub mod chat;
pub mod compact;
pub mod state;
#[cfg(debug_assertions)]
pub mod stats;
pub mod crypto;
pub mod signer;
pub mod db;
pub mod net;
pub mod negentropy;
pub mod blossom;
pub mod blossom_servers;
pub mod blossom_capabilities;
pub mod inbox_relays;
pub mod emoji_packs;
pub mod badges;
pub mod webxdc;
#[cfg(feature = "tor")]
pub mod tor;
pub fn nostr_client_options() -> nostr_sdk::ClientOptions {
let opts = nostr_sdk::ClientOptions::new();
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
{
match tor::transport_state() {
tor::TorTransportState::Active(addr) => {
return opts.connection(nostr_sdk::client::Connection::new().proxy(addr));
}
tor::TorTransportState::RequiredButInactive => {
return opts.connection(
nostr_sdk::client::Connection::new().proxy(tor::blackhole_proxy_addr()),
);
}
tor::TorTransportState::Disabled => {}
}
}
opts
}
pub fn tor_aware_relay_options(opts: nostr_sdk::RelayOptions) -> nostr_sdk::RelayOptions {
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
{
match tor::transport_state() {
tor::TorTransportState::Active(addr) => {
return opts.connection_mode(nostr_sdk::pool::ConnectionMode::proxy(addr));
}
tor::TorTransportState::RequiredButInactive => {
return opts.connection_mode(
nostr_sdk::pool::ConnectionMode::proxy(tor::blackhole_proxy_addr()),
);
}
tor::TorTransportState::Disabled => {}
}
}
opts
}
pub fn community_relay_options() -> nostr_sdk::RelayOptions {
use nostr_sdk::RelayServiceFlags;
tor_aware_relay_options(
nostr_sdk::RelayOptions::new().flags(RelayServiceFlags::GOSSIP | RelayServiceFlags::PING),
)
}
pub mod stored_event;
pub mod rumor;
pub mod sending;
pub mod wallpaper;
pub mod deletion;
pub mod simd;
pub mod community;
pub mod event_handler;
pub use types::{Message, Attachment, Reaction, EditEntry, ImageMetadata, SiteMetadata, LoginResult, AttachmentFile, mention, extract_mentions};
pub use profile::{Profile, ProfileFlags, SlimProfile, Status};
pub use chat::{Chat, ChatType, ChatMetadata, SerializableChat};
pub use compact::{CompactMessage, CompactMessageVec, NpubInterner};
pub use state::{
ChatState, NOSTR_CLIENT, MY_SECRET_KEY, MY_PUBLIC_KEY, STATE, ENCRYPTION_KEY,
nostr_client, my_public_key, has_active_session,
set_nostr_client, set_my_public_key,
take_nostr_client, clear_my_public_key,
set_pending_bunker_setup, pending_bunker_setup, clear_pending_bunker_setup,
};
pub use crypto::{GuardedKey, GuardedSigner};
pub use signer::{
SignerKind, signer_kind, set_signer_kind, is_bunker,
BUNKER_SIGNER, bunker_signer, set_bunker_signer, take_bunker_signer,
build_bunker_signer, prewarm_bunker, drain_bunker_state,
parse_bunker_remote_pubkey, parse_bunker_relays,
BunkerConnectionState, bunker_state, set_bunker_state,
VectorAuthUrlHandler, attempt_bunker_login, WatchedBunkerSigner,
vector_metadata, build_nostrconnect_uri, build_nostrconnect_session,
VECTOR_APP_NAME, VECTOR_APP_URL, VECTOR_APP_ICON,
};
pub use error::{VectorError, Result};
pub use traits::{EventEmitter, NoOpEmitter, set_event_emitter, emit_event};
pub use db::{set_app_data_dir, get_app_data_dir};
pub use sending::{SendCallback, NoOpSendCallback, SendConfig, SendResult};
pub use deletion::{delete_own_dm, DeleteOutcome};
pub use stored_event::{StoredEvent, StoredEventBuilder, SystemEventType};
pub use rumor::{RumorEvent, RumorContext, ConversationType, RumorProcessingResult, process_rumor};
pub use profile::{SyncPriority, ProfileSyncHandler, NoOpProfileSyncHandler};
pub use event_handler::{InboundEventHandler, NoOpEventHandler, PreparedEvent, process_event};
use std::path::PathBuf;
use std::sync::Arc;
pub struct CoreConfig {
pub data_dir: PathBuf,
pub event_emitter: Option<Box<dyn EventEmitter>>,
}
#[derive(Clone, Copy)]
pub struct VectorCore;
impl VectorCore {
pub fn init(config: CoreConfig) -> Result<Self> {
db::set_app_data_dir(config.data_dir);
if let Some(emitter) = config.event_emitter {
traits::set_event_emitter(emitter);
}
let _ = rustls::crypto::ring::default_provider().install_default();
Ok(VectorCore)
}
pub fn accounts(&self) -> Result<Vec<String>> {
db::get_accounts().map_err(VectorError::from)
}
pub async fn login(&self, key: &str, password: Option<&str>) -> Result<LoginResult> {
use nostr_sdk::prelude::*;
let keys = if key.starts_with("nsec1") {
let secret = SecretKey::from_bech32(key)
.map_err(|e| VectorError::Nostr(format!("Invalid nsec: {}", e)))?;
Keys::new(secret)
} else {
Keys::from_mnemonic(key, None)
.map_err(|e| VectorError::Nostr(format!("Key derivation failed: {}", e)))?
};
let public_key = keys.public_key();
let npub = public_key.to_bech32()
.map_err(|e| VectorError::Nostr(format!("Failed to encode npub: {}", e)))?;
let secret_bytes = keys.secret_key().to_secret_bytes();
state::MY_SECRET_KEY.set(secret_bytes, &[&state::ENCRYPTION_KEY]);
state::set_my_public_key(public_key);
db::set_current_account(npub.clone())?;
db::init_database(&npub)?;
{
let nsec = keys.secret_key().to_bech32()
.map_err(|e| VectorError::Nostr(format!("Failed to encode nsec: {}", e)))?;
*state::PENDING_NSEC.lock().unwrap() = Some(nsec.clone());
let existing_encrypted = db::get_pkey().ok().flatten().is_some_and(|v| !v.starts_with("nsec1"));
if !(state::resolve_encryption_enabled_from_db() && existing_encrypted) {
db::set_pkey(&nsec)?;
}
}
let has_encryption = state::resolve_encryption_enabled_from_db();
if has_encryption {
if let Some(pwd) = password {
let key = crate::crypto::hash_pass(pwd).await;
state::ENCRYPTION_KEY.set(key, &[&state::MY_SECRET_KEY]);
}
}
state::init_encryption_enabled();
let client = ClientBuilder::new()
.signer(keys)
.opts(nostr_client_options())
.monitor(Monitor::new(1024))
.build();
for relay in state::TRUSTED_RELAYS {
let opts = tor_aware_relay_options(nostr_sdk::RelayOptions::default());
client.pool().add_relay(*relay, opts).await.ok();
}
client.connect().await;
let _ = { state::set_nostr_client(client); Ok::<(), ()>(()) };
Ok(LoginResult { npub, has_encryption })
}
pub fn generate_nsec(&self) -> Result<String> {
use nostr_sdk::prelude::*;
Keys::generate().secret_key().to_bech32()
.map_err(|e| VectorError::Nostr(format!("Failed to encode nsec: {}", e)))
}
pub async fn send_dm(&self, to_npub: &str, content: &str) -> Result<sending::SendResult> {
sending::send_dm(to_npub, content, None, &SendConfig::default(), Arc::new(NoOpSendCallback)).await
.map_err(|e| VectorError::Other(e))
}
pub async fn send_dm_reply(&self, to_npub: &str, replied_to: &str, content: &str) -> Result<sending::SendResult> {
sending::send_dm(to_npub, content, Some(replied_to), &SendConfig::default(), Arc::new(NoOpSendCallback)).await
.map_err(|e| VectorError::Other(e))
}
pub async fn download_attachment(&self, attachment: &Attachment) -> Result<Vec<u8>> {
use futures_util::StreamExt;
const MAX_DOWNLOAD: usize = 256 * 1024 * 1024;
if attachment.url.is_empty() {
return Err(VectorError::Other("attachment has no URL".into()));
}
crate::net::validate_url_not_private(&attachment.url)
.map_err(|e| VectorError::Other(e.to_string()))?;
let client = crate::net::build_http_client(std::time::Duration::from_secs(120)).map_err(VectorError::Other)?;
let resp = client.get(&attachment.url).send().await
.map_err(|e| VectorError::Other(format!("download: {e}")))?;
if !resp.status().is_success() {
return Err(VectorError::Other(format!("download failed: HTTP {}", resp.status())));
}
let mut encrypted: Vec<u8> = Vec::with_capacity(
resp.content_length().map(|l| (l as usize).min(MAX_DOWNLOAD)).unwrap_or(64 * 1024),
);
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| VectorError::Other(format!("read body: {e}")))?;
if encrypted.len() + chunk.len() > MAX_DOWNLOAD {
return Err(VectorError::Other("attachment exceeds 256 MiB cap".into()));
}
encrypted.extend_from_slice(&chunk);
}
crate::crypto::decrypt_data(&encrypted, &attachment.key, &attachment.nonce).map_err(VectorError::Other)
}
pub async fn send_file(&self, to_npub: &str, file_path: &str) -> Result<sending::SendResult> {
let path = std::path::Path::new(file_path);
let bytes = std::fs::read(path)
.map_err(|e| VectorError::Io(e))?;
let filename = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file");
let extension = path.extension()
.and_then(|e| e.to_str())
.unwrap_or("bin");
sending::send_file_dm(
to_npub,
std::sync::Arc::new(bytes),
filename,
extension,
None,
&SendConfig::default(),
Arc::new(NoOpSendCallback),
).await.map_err(|e| VectorError::Other(e))
}
pub async fn send_reaction(
&self,
to_npub: &str,
reference_id: &str,
emoji: &str,
emoji_url: Option<&str>,
) -> Result<String> {
use nostr_sdk::prelude::*;
let client = state::nostr_client().ok_or(VectorError::Other("Not connected".into()))?;
let my_public_key = state::my_public_key().ok_or(VectorError::Other("Not logged in".into()))?;
let reference_event = EventId::from_hex(reference_id)
.map_err(|e| VectorError::Nostr(e.to_string()))?;
let receiver_pubkey = PublicKey::from_bech32(to_npub)
.map_err(|e| VectorError::Nostr(e.to_string()))?;
let custom_emoji_tag = emoji_url.and_then(|url| {
if !emoji.starts_with(':') || !emoji.ends_with(':') || emoji.len() < 3 || url.is_empty() {
return None;
}
let shortcode = &emoji[1..emoji.len() - 1];
if shortcode.is_empty() { return None; }
Some(Tag::custom(TagKind::custom("emoji"), [shortcode.to_string(), url.to_string()]))
});
let reaction_target = nostr_sdk::nips::nip25::ReactionTarget {
event_id: reference_event,
public_key: receiver_pubkey,
coordinate: None,
kind: Some(Kind::PrivateDirectMessage),
relay_hint: None,
};
let mut builder = EventBuilder::reaction(reaction_target, emoji);
if let Some(tag) = custom_emoji_tag {
builder = builder.tag(tag);
}
let rumor = builder.build(my_public_key);
let rumor_id = rumor.id.ok_or(VectorError::Other("Failed to get rumor ID".into()))?.to_hex();
inbox_relays::send_gift_wrap(&client, &receiver_pubkey, rumor.clone(), [])
.await.map_err(VectorError::Other)?;
let self_wrap_client = client.clone();
let self_wrap_session = state::SessionGuard::capture();
tokio::spawn(async move {
if !self_wrap_session.is_valid() { return; }
let _ = self_wrap_client.gift_wrap(&my_public_key, rumor, []).await;
});
let reaction = Reaction {
id: rumor_id.clone(),
reference_id: reference_id.to_string(),
author_id: my_public_key.to_bech32().unwrap_or_else(|_| my_public_key.to_hex()),
emoji: emoji.to_string(),
emoji_url: emoji_url.map(|s| s.to_string()),
};
let msg_for_save = {
let mut st = state::STATE.lock().await;
match st.add_reaction_to_message(reference_id, reaction) {
Some((cid, true)) => st.find_message(reference_id).map(|(_, m)| (cid, m)),
_ => None,
}
};
if let Some((cid, msg)) = msg_for_save {
let _ = db::events::save_message(&cid, &msg).await;
traits::emit_event_json("message_update", serde_json::json!({
"old_id": reference_id, "message": &msg, "chat_id": &cid
}));
}
Ok(rumor_id)
}
pub async fn send_typing(&self, to_npub: &str) -> Result<()> {
use nostr_sdk::prelude::*;
let client = state::nostr_client().ok_or(VectorError::Other("Not connected".into()))?;
let my_public_key = state::my_public_key().ok_or(VectorError::Other("Not logged in".into()))?;
let pubkey = PublicKey::from_bech32(to_npub).map_err(|e| VectorError::Nostr(e.to_string()))?;
let expiry = Timestamp::from_secs(Timestamp::now().as_secs() + 30);
let rumor = EventBuilder::new(Kind::ApplicationSpecificData, "typing")
.tag(Tag::public_key(pubkey))
.tag(Tag::custom(TagKind::d(), vec!["vector"]))
.tag(Tag::expiration(expiry))
.build(my_public_key);
client.gift_wrap_to(
state::active_trusted_relays().await,
&pubkey,
rumor,
[Tag::expiration(expiry)],
).await.map_err(|e| VectorError::Nostr(e.to_string()))?;
Ok(())
}
pub async fn edit_dm(&self, to_npub: &str, message_id: &str, new_content: &str) -> Result<String> {
use nostr_sdk::prelude::*;
let client = state::nostr_client().ok_or(VectorError::Other("Not connected".into()))?;
let my_public_key = state::my_public_key().ok_or(VectorError::Other("Not logged in".into()))?;
let my_npub = my_public_key.to_bech32().map_err(|e| VectorError::Nostr(e.to_string()))?;
let receiver_pubkey = PublicKey::from_bech32(to_npub).map_err(|e| VectorError::Nostr(e.to_string()))?;
let reference_event = EventId::from_hex(message_id).map_err(|e| VectorError::Nostr(e.to_string()))?;
let emoji_tags = emoji_packs::resolve_outbound_emoji_tags(new_content);
let mut builder = EventBuilder::new(
Kind::from_u16(stored_event::event_kind::MESSAGE_EDIT),
new_content,
).tag(Tag::event(reference_event));
for et in &emoji_tags {
builder = builder.tag(Tag::custom(
TagKind::custom("emoji"),
[et.shortcode.clone(), et.url.clone()],
));
}
let rumor = builder.build(my_public_key);
let edit_id = rumor.id.ok_or(VectorError::Other("Failed to get edit rumor ID".into()))?.to_hex();
let edit_ts_ms = rumor.created_at.as_secs() * 1000;
let msg_for_emit = {
let mut st = state::STATE.lock().await;
st.update_message_in_chat(to_npub, message_id, |msg| {
msg.apply_edit(new_content.to_string(), edit_ts_ms, emoji_tags.clone());
msg.preview_metadata = None;
})
};
if let Some(msg) = msg_for_emit {
traits::emit_event_json("message_update", serde_json::json!({
"old_id": message_id, "message": &msg, "chat_id": to_npub
}));
if let Ok(db_chat_id) = db::id_cache::get_chat_id_by_identifier(to_npub) {
let _ = db::events::save_edit_event(
&edit_id, message_id, new_content, &emoji_tags, db_chat_id, None, &my_npub,
).await;
}
}
inbox_relays::send_gift_wrap(&client, &receiver_pubkey, rumor.clone(), [])
.await.map_err(VectorError::Other)?;
let self_wrap_client = client.clone();
let self_wrap_session = state::SessionGuard::capture();
tokio::spawn(async move {
if !self_wrap_session.is_valid() { return; }
let _ = self_wrap_client.gift_wrap(&my_public_key, rumor, []).await;
});
Ok(edit_id)
}
pub async fn delete_dm(&self, message_id: &str) -> Result<deletion::DeleteOutcome> {
use nostr_sdk::prelude::*;
let rumor_id = EventId::from_hex(message_id).map_err(|e| VectorError::Nostr(e.to_string()))?;
deletion::delete_own_dm(&rumor_id).await.map_err(VectorError::Other)
}
pub async fn get_chats(&self) -> Vec<SerializableChat> {
let state = state::STATE.lock().await;
state.chats.iter()
.map(|c| c.to_serializable_with_last_n(1, &state.interner))
.collect()
}
pub async fn get_messages(&self, chat_id: &str, limit: usize, offset: usize) -> Vec<Message> {
let state = state::STATE.lock().await;
if let Some(chat) = state.get_chat(chat_id) {
let msgs = chat.get_all_messages(&state.interner);
let start = offset.min(msgs.len());
let end = (offset + limit).min(msgs.len());
msgs[start..end].to_vec()
} else {
Vec::new()
}
}
pub async fn get_profile(&self, npub: &str) -> Option<SlimProfile> {
let state = state::STATE.lock().await;
state.get_profile(npub)
.map(|p| SlimProfile::from_profile(p, &state.interner))
}
pub async fn load_profile(&self, npub: &str) -> bool {
profile::sync::load_profile(npub.to_string(), &NoOpProfileSyncHandler).await
}
pub async fn update_profile(&self, name: &str, avatar: &str, banner: &str, about: &str) -> bool {
profile::sync::update_profile(
name.to_string(), avatar.to_string(), banner.to_string(), about.to_string(),
&NoOpProfileSyncHandler,
).await
}
pub async fn update_bot_profile(&self, name: &str, avatar: &str, banner: &str, about: &str) -> bool {
profile::sync::update_bot_profile(
name.to_string(), avatar.to_string(), banner.to_string(), about.to_string(),
&NoOpProfileSyncHandler,
).await
}
pub async fn update_status(&self, status: &str) -> bool {
profile::sync::update_status(status.to_string()).await
}
pub async fn upload_public_image(&self, file_path: &str) -> Result<String> {
let path = std::path::Path::new(file_path);
let bytes = std::fs::read(path).map_err(VectorError::Io)?;
if bytes.is_empty() {
return Err(VectorError::Other("Empty image file".into()));
}
let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("bin").to_lowercase();
let mime = crate::crypto::mime_from_extension(&extension);
let client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
let signer = client
.signer()
.await
.map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
let servers = crate::blossom_servers::compute_enabled_servers();
if servers.is_empty() {
return Err(VectorError::Other("No Blossom servers configured".into()));
}
crate::blossom::upload_blob_with_failover(signer, servers, std::sync::Arc::new(bytes), Some(mime))
.await
.map_err(VectorError::Other)
}
pub async fn block_user(&self, npub: &str) -> bool {
profile::sync::block_user(npub.to_string(), &NoOpProfileSyncHandler).await
}
pub async fn unblock_user(&self, npub: &str) -> bool {
profile::sync::unblock_user(npub.to_string(), &NoOpProfileSyncHandler).await
}
pub async fn set_nickname(&self, npub: &str, nickname: &str) -> bool {
profile::sync::set_nickname(npub.to_string(), nickname.to_string(), &NoOpProfileSyncHandler).await
}
pub async fn get_blocked_users(&self) -> Vec<SlimProfile> {
profile::sync::get_blocked_users().await
}
pub fn queue_profile_sync(&self, npub: &str, priority: SyncPriority) {
profile::sync::queue_profile_sync(npub.to_string(), priority, false);
}
pub fn my_npub(&self) -> Option<String> {
state::my_public_key()
.and_then(|pk| ToBech32::to_bech32(&pk).ok())
}
pub async fn list_communities(&self) -> Vec<serde_json::Value> {
let ids = crate::db::community::list_community_ids().unwrap_or_default();
let mut out = Vec::new();
for id in ids {
if let Ok(Some(c)) = crate::db::community::load_community(&id) {
out.push(serde_json::json!({
"community_id": c.id.to_hex(),
"name": c.name,
"description": c.description,
"is_owner": crate::community::service::is_proven_owner(&c),
"channels": c.channels.iter()
.map(|ch| serde_json::json!({ "channel_id": ch.id.to_hex(), "name": ch.name }))
.collect::<Vec<_>>(),
}));
}
}
out
}
pub async fn join_community(&self, invite_url: &str) -> Result<serde_json::Value> {
use crate::community::{public_invite, service, transport::LiveTransport};
let (relays, token) = public_invite::parse_invite_url(invite_url)
.map_err(|e| VectorError::Other(e.to_string()))?;
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
let bundle = service::fetch_public_invite(&transport, &relays, &token)
.await
.map_err(VectorError::Other)?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let community = service::accept_public_invite(&bundle, now).map_err(VectorError::Other)?;
let attribution = bundle.creator_npub.clone().map(|by| (by, bundle.label.clone()));
self.finalize_member_join(community, &transport, attribution).await
}
pub fn list_pending_invites(&self) -> Result<Vec<serde_json::Value>> {
let rows = crate::db::community::list_pending_invites().map_err(VectorError::Other)?;
Ok(rows.iter().map(|p| {
let name = crate::community::invite::CommunityInvite::from_json(&p.bundle_json)
.ok().map(|i| i.name).unwrap_or_default();
serde_json::json!({
"community_id": p.community_id,
"name": name,
"inviter_npub": p.inviter_npub,
})
}).collect())
}
pub async fn accept_pending_invite(&self, community_id: &str) -> Result<serde_json::Value> {
use crate::community::{invite::{CommunityInvite, accept_invite}, transport::LiveTransport};
let bundle_json = crate::db::community::get_pending_invite(community_id)
.map_err(VectorError::Other)?
.ok_or_else(|| VectorError::Other(format!("no pending invite for {community_id}")))?;
let invite = CommunityInvite::from_json(&bundle_json).map_err(VectorError::Other)?;
let community = accept_invite(&invite).map_err(VectorError::Other)?;
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
let summary = self.finalize_member_join(community, &transport, None).await?;
let _ = crate::db::community::delete_pending_invite(community_id);
Ok(summary)
}
pub(crate) async fn finalize_member_join<T: crate::community::transport::Transport + ?Sized>(
&self,
community: crate::community::Community,
transport: &T,
attribution: Option<(String, Option<String>)>,
) -> Result<serde_json::Value> {
use crate::community::service;
crate::db::community::save_community(&community).map_err(VectorError::Other)?;
if let Ok(c) = service::catch_up_server_root(transport, &community).await {
if c.removed {
let _ = crate::db::community::delete_community(&community.id.to_hex());
return Err(VectorError::Other("you have been removed from this community".into()));
}
}
let community = crate::db::community::load_community(&community.id)
.map_err(VectorError::Other)?
.unwrap_or(community);
let _ = service::fetch_and_apply_control(transport, &community).await;
if service::am_i_banned(&community) {
let _ = crate::db::community::delete_community(&community.id.to_hex());
return Err(VectorError::Other("you are banned from this community".into()));
}
let community = crate::db::community::load_community(&community.id)
.map_err(VectorError::Other)?
.unwrap_or(community);
let owner_npub = community
.owner_attestation
.as_ref()
.and_then(|att| crate::community::owner::verify_owner_attestation(att, &community.id.to_hex()))
.and_then(|pk| ToBech32::to_bech32(&pk).ok());
{
let created_at_ms = crate::db::community::community_created_at_ms(&community.id);
let mut st = state::STATE.lock().await;
for ch in &community.channels {
st.upsert_community_chat(
&ch.id.to_hex(),
&community.name,
community.description.as_deref().unwrap_or(""),
&community.id.to_hex(),
crate::community::service::is_proven_owner(&community),
community.icon.is_some(),
owner_npub.as_deref(),
created_at_ms,
community.dissolved,
);
}
}
if let Some(primary) = community.channels.first() {
let _ = service::publish_presence(transport, &community, primary, true, attribution).await;
}
Ok(serde_json::json!({
"community_id": community.id.to_hex(),
"name": community.name,
"channels": community.channels.iter()
.map(|c| serde_json::json!({ "channel_id": c.id.to_hex(), "name": c.name }))
.collect::<Vec<_>>(),
}))
}
pub async fn create_community(&self, name: &str) -> Result<serde_json::Value> {
use crate::community::{service, transport::LiveTransport};
let relays: Vec<String> = crate::state::active_trusted_relays()
.await
.iter()
.map(|s| s.to_string())
.collect();
if relays.is_empty() {
return Err(VectorError::Other("no relays available to host the Community".into()));
}
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
let community = service::create_community(&transport, name, "general", relays)
.await
.map_err(VectorError::Other)?;
let owner_npub = community
.owner_attestation
.as_ref()
.and_then(|att| crate::community::owner::verify_owner_attestation(att, &community.id.to_hex()))
.and_then(|pk| ToBech32::to_bech32(&pk).ok());
{
let created_at_ms = crate::db::community::community_created_at_ms(&community.id);
let mut st = state::STATE.lock().await;
for ch in &community.channels {
st.upsert_community_chat(
&ch.id.to_hex(),
&community.name,
community.description.as_deref().unwrap_or(""),
&community.id.to_hex(),
crate::community::service::is_proven_owner(&community),
community.icon.is_some(),
owner_npub.as_deref(),
created_at_ms,
community.dissolved,
);
}
}
Ok(serde_json::json!({
"community_id": community.id.to_hex(),
"name": community.name,
"channels": community.channels.iter()
.map(|c| serde_json::json!({ "channel_id": c.id.to_hex(), "name": c.name }))
.collect::<Vec<_>>(),
}))
}
pub async fn create_public_invite(&self, community_id: &str) -> Result<String> {
use crate::community::{service, transport::LiveTransport, CommunityId};
if community_id.len() != 64 {
return Err(VectorError::Other("malformed community id".into()));
}
let community = crate::db::community::load_community(&CommunityId(
crate::simd::hex::hex_to_bytes_32(community_id),
))
.map_err(VectorError::Other)?
.ok_or_else(|| VectorError::Other("community not found".into()))?;
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
let (_token, url) = service::create_public_invite(&transport, &community, None, None)
.await
.map_err(VectorError::Other)?;
Ok(url)
}
pub async fn invite_to_community(&self, community_id: &str, invitee_npub: &str) -> Result<serde_json::Value> {
use crate::community::{service, CommunityId};
use crate::sending::{send_rumor_dm, NoOpSendCallback, SendCallback, SendConfig};
let session = crate::state::SessionGuard::capture();
let my_pk = crate::state::my_public_key()
.ok_or_else(|| VectorError::Other("Public key not set".into()))?;
if community_id.len() != 64 {
return Err(VectorError::Other("malformed community id".into()));
}
let community = crate::db::community::load_community(&CommunityId(
crate::simd::hex::hex_to_bytes_32(community_id),
))
.map_err(VectorError::Other)?
.ok_or_else(|| VectorError::Other("community not found".into()))?;
if !service::caller_has_permission(&community, crate::community::roles::Permissions::CREATE_INVITE) {
return Err(VectorError::Other("You need the create-invite permission to invite someone".into()));
}
let invitee_hex = nostr_sdk::PublicKey::parse(invitee_npub)
.map_err(|_| VectorError::Other("invalid npub".into()))?
.to_hex();
if crate::db::community::get_community_banlist(community_id)
.map_err(VectorError::Other)?
.iter()
.any(|b| b == &invitee_hex)
{
return Err(VectorError::Other("That member is banned from this community and can't be invited".into()));
}
if !session.is_valid() {
return Err(VectorError::Other("account changed during invite".into()));
}
let rumor = crate::community::invite::build_invite_rumor(&community, my_pk).map_err(VectorError::Other)?;
let pending_id = format!("community-invite-{}", community_id);
let config = SendConfig { self_send: false, ..SendConfig::gui() };
let callback: Arc<dyn SendCallback> = Arc::new(NoOpSendCallback);
let result = send_rumor_dm(invitee_npub, &pending_id, rumor, &config, callback)
.await
.map_err(VectorError::Other)?;
Ok(serde_json::json!({
"community_id": community_id,
"invitee": invitee_npub,
"wrap_event_id": result.event_id,
}))
}
pub fn list_public_invites(&self, community_id: &str) -> Result<Vec<crate::db::community::PublicInviteRecord>> {
crate::db::community::list_public_invites(community_id).map_err(VectorError::Other)
}
pub async fn revoke_public_invite(&self, community_id: &str, token: &str) -> Result<()> {
use crate::community::{service, transport::LiveTransport, CommunityId};
if community_id.len() != 64 {
return Err(VectorError::Other("malformed community id".into()));
}
let token_bytes = crate::simd::hex::hex_to_bytes_32(token);
let community = crate::db::community::load_community(&CommunityId(
crate::simd::hex::hex_to_bytes_32(community_id),
))
.map_err(VectorError::Other)?
.ok_or_else(|| VectorError::Other("community not found".into()))?;
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(20));
service::revoke_public_invite(&transport, &community, &token_bytes)
.await
.map_err(VectorError::Other)
}
pub async fn send_community_message(
&self,
channel_id: &str,
content: &str,
replied_to: Option<&str>,
) -> Result<String> {
use crate::community::{envelope, inbound, service, transport::LiveTransport};
let (community, channel) = self.resolve_channel(channel_id)?;
let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
let reply = replied_to.filter(|r| !r.is_empty());
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let unsigned = envelope::build_inner_typed(
author_pk,
&channel.id,
channel.epoch,
crate::stored_event::event_kind::COMMUNITY_MESSAGE,
content,
ms,
reply,
&[],
);
let message_id = unsigned.id.ok_or_else(|| VectorError::Other("inner event has no id".into()))?.to_hex();
let client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
let signer = client.signer().await.map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
let inner = unsigned.sign(&signer).await.map_err(|e| VectorError::Other(format!("sign: {e}")))?;
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
let outer = service::send_signed_message(&transport, &community, &channel, &inner)
.await
.map_err(VectorError::Other)?;
let echoed = {
let mut st = state::STATE.lock().await;
inbound::process_incoming(&mut st, &outer, &channel, &author_pk)
};
if let Some(inbound::IncomingEvent::NewMessage(msg)) = echoed {
let _ = crate::db::events::save_message(channel_id, &msg).await;
}
Ok(message_id)
}
pub async fn send_community_file(&self, channel_id: &str, file_path: &str) -> Result<String> {
use crate::community::{attachments, envelope, inbound, service, transport::LiveTransport};
let path = std::path::Path::new(file_path);
let bytes = std::fs::read(path).map_err(VectorError::Io)?;
if bytes.is_empty() {
return Err(VectorError::Other("Empty file".into()));
}
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file").to_string();
let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("bin").to_lowercase();
let (community, channel) = self.resolve_channel(channel_id)?;
let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
let file_hash = crate::crypto::sha256_hex(&bytes);
let mime = crate::crypto::mime_from_extension(&extension);
let img_meta = crate::crypto::generate_image_metadata(&bytes);
let download_dir = crate::db::get_download_dir();
let _ = std::fs::create_dir_all(&download_dir);
let local_name = if filename.is_empty() { format!("{}.{}", &file_hash, extension) } else { filename.clone() };
let local_path = crate::crypto::resolve_unique_filename(&download_dir, &local_name);
let _ = std::fs::write(&local_path, &bytes);
let params = crate::crypto::generate_encryption_params();
let encrypted = crate::crypto::encrypt_data(&bytes, ¶ms)?;
let encrypted_size = encrypted.len() as u64;
let client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
let signer = client.signer().await.map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
let servers = crate::blossom_servers::compute_enabled_servers();
if servers.is_empty() {
return Err(VectorError::Other("No Blossom servers configured".into()));
}
let noop_progress: crate::blossom::ProgressCallback = std::sync::Arc::new(|_, _| Ok(()));
let url = crate::blossom::upload_blob_with_progress_and_failover(
signer.clone(),
servers,
std::sync::Arc::new(encrypted),
Some(mime),
true,
noop_progress,
Some(3),
Some(std::time::Duration::from_secs(2)),
None,
).await.map_err(VectorError::Other)?;
let attachment = crate::types::Attachment {
id: file_hash.clone(),
key: params.key.clone(),
nonce: params.nonce.clone(),
extension: extension.clone(),
name: filename.clone(),
url,
path: local_path.to_string_lossy().to_string(),
size: encrypted_size,
img_meta,
downloading: false,
downloaded: true,
..Default::default()
};
let imeta = vec![attachments::attachment_to_imeta(&attachment)];
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let unsigned = envelope::build_inner_full(
author_pk, &channel.id, channel.epoch,
stored_event::event_kind::COMMUNITY_MESSAGE, "", ms, None, &[], &imeta,
);
let message_id = unsigned.id.ok_or_else(|| VectorError::Other("inner event has no id".into()))?.to_hex();
let inner = unsigned.sign(&signer).await.map_err(|e| VectorError::Other(format!("sign: {e}")))?;
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(30));
let outer = service::send_signed_message(&transport, &community, &channel, &inner)
.await.map_err(VectorError::Other)?;
let echoed = {
let mut st = state::STATE.lock().await;
inbound::process_incoming(&mut st, &outer, &channel, &author_pk)
};
if let Some(inbound::IncomingEvent::NewMessage(m)) = echoed {
let _ = crate::db::events::save_message(channel_id, &m).await;
}
Ok(message_id)
}
pub async fn send_community_typing(&self, channel_id: &str) -> Result<()> {
use crate::community::{service, transport::LiveTransport};
let (community, channel) = self.resolve_channel(channel_id)?;
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(8));
service::publish_typing_signal(&transport, &community, &channel)
.await
.map_err(VectorError::Other)
}
pub async fn send_community_reaction(
&self,
channel_id: &str,
message_id: &str,
emoji: &str,
emoji_url: Option<&str>,
) -> Result<()> {
let emoji_tags: Vec<crate::types::EmojiTag> = match emoji_url {
Some(url) if emoji.starts_with(':') && emoji.ends_with(':') && emoji.len() >= 3 && !url.is_empty() => {
vec![crate::types::EmojiTag { shortcode: emoji[1..emoji.len() - 1].to_string(), url: url.to_string() }]
}
_ => Vec::new(),
};
self.publish_community_control(
channel_id, stored_event::event_kind::COMMUNITY_REACTION, emoji, message_id, &emoji_tags,
).await
}
pub async fn edit_community_message(&self, channel_id: &str, message_id: &str, new_content: &str) -> Result<()> {
let emoji_tags = emoji_packs::resolve_outbound_emoji_tags(new_content);
self.publish_community_control(
channel_id, stored_event::event_kind::COMMUNITY_EDIT, new_content, message_id, &emoji_tags,
).await
}
pub async fn delete_community_message(&self, message_id: &str) -> Result<()> {
use crate::community::{service, transport::LiveTransport};
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
let (channel_id, attachment_urls) = {
let st = state::STATE.lock().await;
match st.find_message(message_id) {
Some((chat, msg)) => (
chat.id.clone(),
msg.attachments.iter().filter(|a| !a.url.is_empty()).map(|a| a.url.clone()).collect::<Vec<_>>(),
),
None => return Err(VectorError::Other("message not found (already deleted?)".into())),
}
};
if crate::db::community::get_message_key(message_id).map(|k| k.is_some()).unwrap_or(false) {
let _ = service::delete_message(&transport, message_id).await;
}
self.publish_community_control(
&channel_id, stored_event::event_kind::COMMUNITY_DELETE, "", message_id, &[],
).await?;
if !attachment_urls.is_empty() {
if let Some(client) = state::nostr_client() {
if let Ok(signer) = client.signer().await {
crate::blossom::delete_blobs_best_effort(signer, attachment_urls);
}
}
}
let removed_chat = {
let mut st = state::STATE.lock().await;
st.remove_message(message_id).map(|(cid, _)| cid)
};
let _ = crate::db::events::delete_event(message_id).await;
traits::emit_event_json("message_removed", serde_json::json!({
"id": message_id, "chat_id": removed_chat.as_deref().unwrap_or(&channel_id), "reason": "deleted",
}));
Ok(())
}
async fn publish_community_control(
&self,
channel_id: &str,
kind: u16,
content: &str,
target: &str,
emoji_tags: &[crate::types::EmojiTag],
) -> Result<()> {
use crate::community::{envelope, inbound, service, transport::LiveTransport};
let (community, channel) = self.resolve_channel(channel_id)?;
let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let unsigned = envelope::build_inner_typed(
author_pk, &channel.id, channel.epoch, kind, content, ms, Some(target), emoji_tags,
);
let client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
let signer = client.signer().await.map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
let inner = unsigned.sign(&signer).await.map_err(|e| VectorError::Other(format!("sign: {e}")))?;
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
let outer = service::send_signed_message(&transport, &community, &channel, &inner)
.await.map_err(VectorError::Other)?;
let outcome = {
let mut st = state::STATE.lock().await;
inbound::process_incoming(&mut st, &outer, &channel, &author_pk)
};
if let Some(inbound::IncomingEvent::Updated { target_id, message, edit_event }) = outcome {
if let Some(ev) = edit_event {
let mut ev = (*ev).clone();
if let Ok(cid) = crate::db::id_cache::get_chat_id_by_identifier(channel_id) { ev.chat_id = cid; }
let _ = crate::db::events::save_event(&ev).await;
} else {
let _ = crate::db::events::save_message(channel_id, &message).await;
}
traits::emit_event_json("message_update", serde_json::json!({
"old_id": target_id, "message": &message, "chat_id": channel_id,
}));
}
Ok(())
}
pub async fn sync_community_channel(&self, channel_id: &str, limit: usize) -> Result<(usize, Vec<String>)> {
use crate::community::{inbound, send, service, transport::LiveTransport};
let my_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
let (community, _) = self.resolve_channel(channel_id)?;
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
let mut warnings: Vec<String> = Vec::new();
match service::catch_up_server_root(&transport, &community).await {
Ok(c) if c.removed => {
let _ = crate::db::community::delete_community_retain_keys(&community.id.to_hex());
return Ok((0, warnings));
}
Ok(_) => {}
Err(e) => warnings.push(format!("base catch-up failed: {e}")),
}
let (community, _) = self.resolve_channel(channel_id)?;
if let Err(e) = service::fetch_and_apply_control(&transport, &community).await {
warnings.push(format!("control fold failed: {e}"));
}
if service::am_i_banned(&community) {
let _ = crate::db::community::delete_community_retain_keys(&community.id.to_hex());
return Ok((0, warnings));
}
let (community, channel) = self.resolve_channel(channel_id)?;
if let Err(e) = service::catch_up_channel_rekeys(&transport, &community, &channel.id).await {
warnings.push(format!("channel catch-up failed: {e}"));
}
let (community, _) = self.resolve_channel(channel_id)?;
if let Err(e) = service::retry_pending_read_cut(&transport, &community).await {
warnings.push(format!("read-cut resume failed: {e}"));
}
let (community, channel) = self.resolve_channel(channel_id)?;
let events = send::fetch_channel_page(&transport, &community, &channel, None, None, limit.max(1))
.await
.map_err(VectorError::Other)?;
let outcomes = {
let mut st = state::STATE.lock().await;
inbound::process_channel_batch(&mut st, &events, &channel, &my_pk)
};
let mut new = 0usize;
for o in &outcomes {
match o {
inbound::IncomingEvent::NewMessage(m) => {
let _ = crate::db::events::save_message(channel_id, m).await;
new += 1;
}
inbound::IncomingEvent::Updated { message, .. } => {
let _ = crate::db::events::save_message(channel_id, message).await;
}
inbound::IncomingEvent::Removed { target_id } => {
let _ = crate::db::events::delete_event(target_id).await;
}
inbound::IncomingEvent::Presence { npub, joined, event_id, created_at, invited_by, invited_label } => {
let et = if *joined {
crate::stored_event::SystemEventType::MemberJoined
} else {
crate::stored_event::SystemEventType::MemberLeft
};
let note = invited_by.as_ref().map(|by| match invited_label {
Some(l) if !l.is_empty() => format!("{by}|{l}"),
_ => by.clone(),
});
let _ = crate::db::events::save_system_event_at(event_id, channel_id, et, npub, note.as_deref(), *created_at, invited_by.as_deref(), invited_label.as_deref()).await;
}
inbound::IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, event_id, created_at } => {
community::service::persist_webxdc_signal(
channel_id, npub, topic_id, node_addr.as_deref(), event_id, *created_at,
).await;
}
inbound::IncomingEvent::Kicked { community_id }
| inbound::IncomingEvent::SelfLeft { community_id } => {
let _ = crate::db::community::delete_community_retain_keys(community_id);
break;
}
inbound::IncomingEvent::Typing { .. } => {
}
}
}
Ok((new, warnings))
}
pub async fn get_community_members(&self, community_id: &str) -> Vec<serde_json::Value> {
crate::db::community::community_member_activity(community_id)
.unwrap_or_default()
.into_iter()
.map(|(npub, last_active)| serde_json::json!({ "npub": npub, "last_active": last_active }))
.collect()
}
fn load_community_hex(community_id: &str) -> Result<crate::community::Community> {
use crate::community::CommunityId;
if community_id.len() != 64 {
return Err(VectorError::Other("malformed community id".into()));
}
crate::db::community::load_community(&CommunityId(crate::simd::hex::hex_to_bytes_32(community_id)))
.map_err(VectorError::Other)?
.ok_or_else(|| VectorError::Other("community not found".into()))
}
fn admin_role_id_of(community_id: &str) -> Result<String> {
let roles = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
roles.roles.iter()
.find(|r| matches!(r.scope, crate::community::roles::RoleScope::Server)
&& r.permissions.contains(crate::community::roles::Permissions::ADMIN_ALL))
.map(|r| r.role_id.clone())
.ok_or_else(|| VectorError::Other("admin role not found (roster not synced?)".into()))
}
pub fn community_capabilities(&self, community_id: &str) -> Result<serde_json::Value> {
use crate::community::service;
let community = Self::load_community_hex(community_id)?;
let caps = service::caller_capabilities(&community);
let manage_admin_role = Self::admin_role_id_of(community_id).ok()
.map(|rid| service::caller_can_manage_role_id(&community, &rid))
.unwrap_or(false);
Ok(serde_json::json!({
"manage_metadata": caps.manage_metadata, "manage_channels": caps.manage_channels,
"create_invite": caps.create_invite, "kick": caps.kick, "ban": caps.ban,
"manage_messages": caps.manage_messages, "manage_roles": caps.manage_roles,
"manage_admin_role": manage_admin_role,
}))
}
pub fn community_roles(&self, community_id: &str) -> Result<serde_json::Value> {
use nostr_sdk::prelude::{PublicKey, ToBech32};
let community = Self::load_community_hex(community_id)?;
let owner = community.owner_attestation.as_ref()
.and_then(|att| crate::community::owner::verify_owner_attestation(att, &community.id.to_hex()))
.and_then(|pk| ToBech32::to_bech32(&pk).ok());
let roles = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
let admins: Vec<String> = roles.grants.iter().filter(|g| roles.is_admin(&g.member))
.filter_map(|g| PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()))
.collect();
Ok(serde_json::json!({ "owner": owner, "admins": admins }))
}
pub async fn grant_admin(&self, community_id: &str, npub: &str) -> Result<()> {
use crate::community::{service, transport::LiveTransport};
let member = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
let community = Self::load_community_hex(community_id)?;
let role_id = Self::admin_role_id_of(community_id)?;
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
service::grant_role(&transport, &community, member, &role_id).await.map_err(VectorError::Other)
}
pub async fn revoke_admin(&self, community_id: &str, npub: &str) -> Result<()> {
use crate::community::{service, transport::LiveTransport};
let member = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
let community = Self::load_community_hex(community_id)?;
let role_id = Self::admin_role_id_of(community_id)?;
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
service::revoke_role(&transport, &community, member, &role_id).await.map_err(VectorError::Other)
}
pub async fn kick_member(&self, community_id: &str, npub: &str) -> Result<()> {
use crate::community::{service, transport::LiveTransport};
let hex = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?.to_hex();
let community = Self::load_community_hex(community_id)?;
let channel = community.channels.first().ok_or_else(|| VectorError::Other("community has no channel".into()))?;
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
service::publish_kick(&transport, &community, channel, &hex).await.map(|_| ()).map_err(VectorError::Other)
}
pub async fn set_member_banned(&self, community_id: &str, npub: &str, banned: bool) -> Result<()> {
use crate::community::{service, transport::LiveTransport};
let hex = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?.to_hex();
let community = Self::load_community_hex(community_id)?;
let mut list = crate::db::community::get_community_banlist(community_id).map_err(VectorError::Other)?;
list.retain(|h| h != &hex);
if banned { list.push(hex); }
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
service::publish_banlist(&transport, &community, &list).await.map_err(VectorError::Other)
}
pub async fn dissolve_community(&self, community_id: &str) -> Result<()> {
use crate::community::{service, transport::LiveTransport};
let community = Self::load_community_hex(community_id)?;
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
service::dissolve_community(&transport, &community).await.map_err(VectorError::Other)
}
pub async fn edit_community_metadata(&self, community_id: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
use crate::community::{service, transport::LiveTransport};
let mut community = Self::load_community_hex(community_id)?;
if let Some(n) = name { community.name = n.to_string(); }
if let Some(d) = description { community.description = if d.is_empty() { None } else { Some(d.to_string()) }; }
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
service::republish_community_metadata(&transport, &community).await.map_err(VectorError::Other)
}
pub async fn leave_community(&self, community_id: &str) -> Result<()> {
use crate::community::{transport::LiveTransport, CommunityId};
if community_id.len() != 64 {
return Err(VectorError::Other("malformed community id".into()));
}
let id = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
let community = crate::db::community::load_community(&id).map_err(VectorError::Other)?;
let channel_ids: Vec<String> = community
.as_ref()
.map(|c| c.channels.iter().map(|ch| ch.id.to_hex()).collect())
.unwrap_or_default();
if let Some(ref c) = community {
if let Some(primary) = c.channels.first() {
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
let _ = crate::community::service::publish_presence(&transport, c, primary, false, None).await;
}
}
crate::db::community::delete_community_retain_keys(community_id).map_err(VectorError::Other)?;
{
let mut st = state::STATE.lock().await;
st.chats.retain(|c| !channel_ids.contains(&c.id));
}
Ok(())
}
fn resolve_channel(
&self,
channel_id: &str,
) -> Result<(crate::community::Community, crate::community::Channel)> {
use crate::community::CommunityId;
let community_id = crate::db::community::community_id_for_channel(channel_id)
.map_err(VectorError::Other)?
.ok_or_else(|| VectorError::Other("Unknown Community channel".into()))?;
if community_id.len() != 64 {
return Err(VectorError::Other("malformed community id".into()));
}
let community = crate::db::community::load_community(&CommunityId(
crate::simd::hex::hex_to_bytes_32(&community_id),
))
.map_err(VectorError::Other)?
.ok_or_else(|| VectorError::Other("Community not found".into()))?;
let channel = community
.channels
.iter()
.find(|c| c.id.to_hex() == channel_id)
.cloned()
.ok_or_else(|| VectorError::Other("Channel not found in Community".into()))?;
Ok((community, channel))
}
pub async fn sync_dms(
&self,
since_days: Option<u64>,
handler: &dyn InboundEventHandler,
) -> Result<(u32, u32)> {
use futures_util::StreamExt;
use nostr_sdk::prelude::*;
let client = state::nostr_client()
.ok_or(VectorError::Other("Not connected".into()))?;
let my_pk = state::my_public_key()
.ok_or(VectorError::Other("Not logged in".into()))?;
let all_items = db::wrappers::load_negentropy_items().unwrap_or_default();
let (items, filter) = if let Some(days) = since_days {
let since_ts = Timestamp::now().as_secs().saturating_sub(days * 24 * 3600);
let items: Vec<(EventId, Timestamp)> = all_items.iter()
.filter(|(_, ts)| ts.as_secs() >= since_ts)
.cloned()
.collect();
let filter = Filter::new()
.pubkey(my_pk)
.kind(Kind::GiftWrap)
.since(Timestamp::from_secs(since_ts));
(items, filter)
} else {
let filter = Filter::new()
.pubkey(my_pk)
.kind(Kind::GiftWrap);
(all_items, filter)
};
log_info!("[SyncDMs] {} negentropy items, since_days={:?}", items.len(), since_days);
let sync_opts = nostr_sdk::SyncOptions::new()
.direction(nostr_sdk::SyncDirection::Down)
.initial_timeout(std::time::Duration::from_secs(10))
.dry_run();
let relay_map = client.relays().await;
let all_relays: Vec<(RelayUrl, Relay)> = relay_map.iter()
.map(|(url, relay)| (url.clone(), relay.clone()))
.collect();
drop(relay_map);
let mut relay_futs = futures_util::stream::FuturesUnordered::new();
for (url, relay) in &all_relays {
let url = url.clone();
let relay = relay.clone();
let f = filter.clone();
let i = items.clone();
let o = sync_opts.clone();
relay_futs.push(async move {
let result = tokio::time::timeout(
std::time::Duration::from_secs(10),
relay.sync_with_items(f, i, &o),
).await;
(url, result)
});
}
let mut all_missing: std::collections::HashSet<EventId> = std::collections::HashSet::new();
while let Some((url, result)) = relay_futs.next().await {
match result {
Ok(Ok(recon)) => {
let count = recon.remote.len();
all_missing.extend(recon.remote);
log_info!("[SyncDMs] {} reconciled: {} missing", url, count);
}
Ok(Err(e)) => log_warn!("[SyncDMs] {} failed: {}", url, e),
Err(_) => log_warn!("[SyncDMs] {} timed out (10s)", url),
}
}
if all_missing.is_empty() {
log_info!("[SyncDMs] No missing events");
return Ok((0, 0));
}
log_info!("[SyncDMs] Fetching {} missing events", all_missing.len());
let ids: Vec<EventId> = all_missing.into_iter().collect();
let relay_strs: Vec<String> = client.relays().await.keys()
.map(|u| u.to_string()).collect();
let mut total_events = 0u32;
let mut new_messages = 0u32;
const BATCH_SIZE: usize = 500;
for batch in ids.chunks(BATCH_SIZE) {
let f = Filter::new().ids(batch.to_vec()).kind(Kind::GiftWrap);
match client.stream_events_from(
relay_strs.clone(), f,
std::time::Duration::from_secs(30),
).await {
Ok(stream) => {
let client_clone = client.clone();
let prepared_stream = stream
.map(move |event| {
let c = client_clone.clone();
tokio::spawn(async move {
event_handler::prepare_event(event, &c, my_pk).await
})
})
.buffer_unordered(8);
tokio::pin!(prepared_stream);
while let Some(result) = prepared_stream.next().await {
total_events += 1;
if let Ok(prepared) = result {
if event_handler::commit_prepared_event(prepared, false, handler).await {
new_messages += 1;
}
}
}
}
Err(e) => log_warn!("[SyncDMs] Batch fetch error: {}", e),
}
}
log_info!("[SyncDMs] Complete: {} events processed, {} new messages", total_events, new_messages);
Ok((total_events, new_messages))
}
pub async fn subscribe_dms(&self) -> Result<nostr_sdk::SubscriptionId> {
use nostr_sdk::prelude::*;
let client = state::nostr_client()
.ok_or(VectorError::Other("Not connected".into()))?;
let my_pk = state::my_public_key()
.ok_or(VectorError::Other("Not logged in".into()))?;
let filter = Filter::new()
.pubkey(my_pk)
.kind(Kind::GiftWrap)
.limit(0);
let output = client.subscribe(filter, None).await
.map_err(|e| VectorError::Nostr(e.to_string()))?;
Ok(output.val)
}
pub async fn sync_communities(&self) -> Result<()> {
let ids = db::community::list_community_ids().map_err(VectorError::from)?;
for id in ids {
if let Ok(Some(community)) = db::community::load_community(&id) {
for ch in &community.channels {
let _ = self.sync_community_channel(&ch.id.to_hex(), 50).await;
}
}
}
Ok(())
}
pub async fn listen(&self, handler: Arc<dyn InboundEventHandler>) -> Result<()> {
use nostr_sdk::prelude::*;
let client = state::nostr_client()
.ok_or(VectorError::Other("Not connected".into()))?;
let my_pk = state::my_public_key()
.ok_or(VectorError::Other("Not logged in".into()))?;
let _ = self.sync_communities().await;
let _ = self.sync_dms(None, &NoOpEventHandler).await;
let dm_sub_id = self.subscribe_dms().await?;
community::realtime::refresh_subscription(&client).await;
if let Some(monitor) = client.monitor() {
let mut rx = monitor.subscribe();
let session = state::SessionGuard::capture();
tokio::spawn(async move {
let mut last_resync: Option<std::time::Instant> = None;
while let Ok(notification) = rx.recv().await {
if !session.is_valid() {
return;
}
let MonitorNotification::StatusChanged { status, .. } = notification;
if status == RelayStatus::Connected {
if last_resync.is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(3)) {
continue;
}
let _ = VectorCore.sync_communities().await;
let _ = VectorCore.sync_dms(None, &NoOpEventHandler).await;
if let Some(c) = state::nostr_client() {
community::realtime::refresh_subscription(&c).await;
}
last_resync = Some(std::time::Instant::now());
}
}
});
}
{
let client_health = client.clone();
let session = state::SessionGuard::capture();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(30)).await; loop {
if !session.is_valid() {
return;
}
for (url, relay) in client_health.relays().await {
match relay.status() {
RelayStatus::Connected => {
let probe = tokio::time::timeout(
std::time::Duration::from_secs(10),
client_health.fetch_events_from(
vec![url.to_string()],
Filter::new().kind(Kind::Metadata).limit(1),
std::time::Duration::from_secs(8),
),
)
.await;
if !matches!(probe, Ok(Ok(_))) {
let _ = relay.disconnect();
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let _ = relay.try_connect(std::time::Duration::from_secs(10)).await;
}
}
RelayStatus::Terminated | RelayStatus::Disconnected => {
let _ = relay.try_connect(std::time::Duration::from_secs(10)).await;
}
_ => {}
}
}
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
}
});
}
let client_for_closure = client.clone();
client.handle_notifications(move |notification| {
let handler = handler.clone();
let c = client_for_closure.clone();
let dm_sid = dm_sub_id.clone();
async move {
if let RelayPoolNotification::Event { event, subscription_id, .. } = notification {
if subscription_id == dm_sid {
let prepared = event_handler::prepare_event(*event, &c, my_pk).await;
event_handler::commit_prepared_event(prepared, true, &*handler).await;
} else if community::realtime::subscription_id().await.as_ref() == Some(&subscription_id) {
let session = state::SessionGuard::capture();
community::realtime::dispatch_event(&session, *event, handler.clone()).await;
}
}
Ok(false)
}
}).await.map_err(|e| VectorError::Nostr(e.to_string()))?;
Ok(())
}
pub async fn logout(&self) {
if let Some(client) = state::nostr_client() {
let _ = client.disconnect().await;
}
db::close_database();
}
pub async fn swap_session(&self) {
state::bump_session_generation();
if let Some(client) = state::take_nostr_client() {
let _ = client.shutdown().await;
}
db::close_database();
state::ENCRYPTION_KEY.clear(&[&state::MY_SECRET_KEY]);
state::MY_SECRET_KEY.clear(&[&state::ENCRYPTION_KEY]);
{
use zeroize::Zeroize;
if let Ok(mut g) = state::MNEMONIC_SEED.lock() {
if let Some(s) = g.as_mut() { s.zeroize(); }
*g = None;
}
if let Ok(mut g) = state::PENDING_NSEC.lock() {
if let Some(s) = g.as_mut() { s.zeroize(); }
*g = None;
}
}
{
let mut st = state::STATE.lock().await;
st.profiles.clear();
st.chats.clear();
st.db_loaded = false;
st.is_syncing = false;
}
state::WRAPPER_ID_CACHE.lock().await.clear();
state::PENDING_EVENTS.lock().await.clear();
state::set_active_chat(None);
crate::profile::sync::clear_profile_sync_queue();
crate::inbox_relays::clear_inbox_relay_cache();
crate::emoji_packs::clear_nip65_cache();
crate::db::clear_id_caches();
crate::community::cache::clear();
crate::community::realtime::clear().await;
crate::emoji_packs::set_theme_emoji_tags(Vec::new());
}
}
#[cfg(test)]
mod facade_tests {
use super::*;
#[tokio::test]
async fn download_attachment_rejects_private_url() {
let att = crate::types::Attachment {
url: "http://169.254.169.254/latest/meta-data/".to_string(),
..Default::default()
};
match VectorCore.download_attachment(&att).await {
Err(VectorError::Other(msg)) => {
assert!(msg.contains("Private/internal"), "expected SSRF rejection, got: {msg}")
}
other => panic!("expected SSRF rejection, got {other:?}"),
}
}
#[tokio::test]
async fn download_attachment_rejects_empty_url() {
let att = crate::types::Attachment::default();
assert!(VectorCore.download_attachment(&att).await.is_err());
}
}