diff --git a/conversations/examples/chat_session.rs b/conversations/examples/chat_session.rs deleted file mode 100644 index 73f5592..0000000 --- a/conversations/examples/chat_session.rs +++ /dev/null @@ -1,139 +0,0 @@ -//! Example: Chat Session with Automatic Persistence -//! -//! This example demonstrates using ChatSession which automatically -//! persists all state changes to SQLite storage. -//! -//! Run with: cargo run -p logos-chat --example chat_session - -use logos_chat::storage::ChatSession; -use tempfile::TempDir; - -fn main() { - println!("=== Chat Session Example ===\n"); - - // Create temporary directories for databases - let alice_dir = TempDir::new().expect("Failed to create temp dir"); - let bob_dir = TempDir::new().expect("Failed to create temp dir"); - - let alice_db = alice_dir.path().join("alice.db"); - let bob_db = bob_dir.path().join("bob.db"); - - // ========================================= - // Create sessions for Alice and Bob - // ========================================= - println!("Step 1: Creating chat sessions...\n"); - - let mut alice = ChatSession::open_or_create(alice_db.to_str().unwrap(), "alice_secret_key") - .expect("Failed to create Alice's session"); - println!(" Alice's session created"); - println!(" Address: {}", &alice.local_address()); - - let mut bob = ChatSession::open_or_create(bob_db.to_str().unwrap(), "bob_secret_key") - .expect("Failed to create Bob's session"); - println!(" Bob's session created"); - println!(" Address: {}", &bob.local_address()); - println!(); - - // ========================================= - // Bob creates intro bundle - // ========================================= - println!("Step 2: Bob creates introduction bundle...\n"); - let bob_intro = bob - .create_intro_bundle() - .expect("Failed to create intro bundle"); - println!(" Bob's intro bundle created"); - println!( - " Installation key: {}", - &hex::encode(bob_intro.installation_key.as_bytes()) - ); - println!(); - - // ========================================= - // Alice starts a chat with Bob - // ========================================= - println!("Step 3: Alice starts a private chat with Bob...\n"); - let (chat_id, envelopes) = alice - .start_private_chat(&bob_intro, "Hello Bob! 👋") - .expect("Failed to start chat"); - - println!(" Chat created: {}", chat_id); - println!(" Envelopes to deliver: {}", envelopes.len()); - println!(" Chat automatically saved to storage!"); - println!(); - - // ========================================= - // Verify persistence by checking storage - // ========================================= - println!("Step 4: Verifying persistence...\n"); - - // Check Alice's storage directly - let chat_record = alice - .storage() - .load_chat(&chat_id) - .expect("Failed to load chat"); - - if let Some(record) = chat_record { - println!(" Chat record found in storage:"); - println!(" - ID: {}", record.chat_id); - println!(" - Type: {}", record.chat_type); - println!(" - Remote: {}", record.remote_address); - } - println!(); - - // ========================================= - // Alice sends more messages - // ========================================= - println!("Step 5: Alice sends more messages...\n"); - - let messages = [ - "How are you?", - "Are you there?", - "Let me know when you're free!", - ]; - for msg in &messages { - let envelopes = alice - .send_message(&chat_id, msg.as_bytes()) - .expect("Failed to send message"); - println!(" Sent: \"{}\" ({} envelope(s))", msg, envelopes.len()); - } - println!(); - - // ========================================= - // Simulate app restart - reopen session - // ========================================= - println!("Step 6: Simulating app restart...\n"); - drop(alice); // Close Alice's session - - println!(" Session closed. Reopening...\n"); - - let alice_restored = ChatSession::open(alice_db.to_str().unwrap(), "alice_secret_key") - .expect("Failed to reopen Alice's session"); - - println!(" Session restored!"); - println!(" Address: {}", &alice_restored.local_address()); - - // Note: The chats list will be empty because we haven't implemented - // full chat restoration yet (which requires restoring ratchet states) - println!(" Chats in memory: {}", alice_restored.list_chats().len()); - - // But the chat metadata is persisted in storage - let stored_chats = alice_restored - .storage() - .list_chat_ids() - .expect("Failed to list chats"); - println!(" Chats in storage: {:?}", stored_chats); - println!(); - - println!("=== Chat Session Example Complete ===\n"); - println!("Key features demonstrated:"); - println!(" ✓ Automatic identity persistence"); - println!(" ✓ Automatic chat metadata persistence"); - println!(" ✓ Session recovery after restart"); - println!(); - println!("TODO:"); - println!(" - Full inbox key persistence"); - println!(" - Ratchet state persistence (integration with double-ratchets storage)"); - println!(" - Complete chat restoration on session open"); - - // Temp directories are automatically cleaned up when dropped -} diff --git a/conversations/examples/persist_chat.rs b/conversations/examples/persist_chat.rs new file mode 100644 index 0000000..ecf59d0 --- /dev/null +++ b/conversations/examples/persist_chat.rs @@ -0,0 +1,135 @@ +//! Example: Chat Flow with Automatic Persistence +//! +//! This example demonstrates the complete chat flow using ChatManager, +//! which automatically handles all storage operations. +//! +//! Run with: cargo run -p logos-chat --example chat_sesspersist_chat + +use logos_chat::{ChatManager, StorageConfig}; +use tempfile::TempDir; + +fn main() { + println!("=== Chat Flow Example ===\n"); + + // Create temporary directories for databases + let alice_dir = TempDir::new().expect("Failed to create temp dir"); + let bob_dir = TempDir::new().expect("Failed to create temp dir"); + + let alice_db = alice_dir.path().join("alice.db"); + let bob_db = bob_dir.path().join("bob.db"); + + // ========================================= + // Step 1: Create chat managers + // ========================================= + println!("Step 1: Creating chat managers...\n"); + + // In production, use StorageConfig::Encrypted { path, key } + let mut alice = ChatManager::open(StorageConfig::File(alice_db.to_str().unwrap().to_string())) + .expect("Failed to create Alice's chat manager"); + + let mut bob = ChatManager::open(StorageConfig::File(bob_db.to_str().unwrap().to_string())) + .expect("Failed to create Bob's chat manager"); + + println!(" Alice's address: {}", alice.local_address()); + println!(" Bob's address: {}", bob.local_address()); + println!(); + + // ========================================= + // Step 2: Bob creates intro bundle to share + // ========================================= + println!("Step 2: Bob creates introduction bundle...\n"); + + let bob_intro = bob + .create_intro_bundle() + .expect("Failed to create intro bundle"); + + println!(" Bob shares his intro bundle with Alice"); + println!( + " (Installation key: {})", + hex::encode(bob_intro.installation_key.as_bytes()) + ); + println!(); + + // ========================================= + // Step 3: Alice starts a chat with Bob + // ========================================= + println!("Step 3: Alice starts a private chat with Bob...\n"); + + let (chat_id, envelopes) = alice + .start_private_chat(&bob_intro, "Hello Bob! 👋") + .expect("Failed to start chat"); + + println!(" Chat created: {}", chat_id); + println!(" Envelopes to deliver: {}", envelopes.len()); + println!(" (Chat automatically persisted to storage)"); + println!(); + + // ========================================= + // Step 4: Alice sends more messages + // ========================================= + println!("Step 4: Alice sends more messages...\n"); + + let messages = ["How are you?", "Are you there?", "☕"]; + for msg in &messages { + let envelopes = alice + .send_message(&chat_id, msg.as_bytes()) + .expect("Failed to send message"); + println!(" → \"{}\" ({} envelope)", msg, envelopes.len()); + } + println!(); + + // ========================================= + // Step 5: Verify persistence + // ========================================= + println!("Step 5: Verifying persistence...\n"); + + println!(" Active chats in memory: {:?}", alice.list_chats()); + println!( + " Chats persisted to storage: {:?}", + alice.list_stored_chats().unwrap() + ); + println!(); + + // ========================================= + // Step 6: Simulate app restart + // ========================================= + println!("Step 6: Simulating app restart...\n"); + + let alice_address = alice.local_address(); + drop(alice); // Close Alice's chat manager + + println!(" Chat manager closed. Reopening...\n"); + + let alice_restored = + ChatManager::open(StorageConfig::File(alice_db.to_str().unwrap().to_string())) + .expect("Failed to reopen Alice's chat manager"); + + println!(" ✓ Chat manager restored!"); + println!( + " ✓ Same address: {}", + alice_restored.local_address() == alice_address + ); + println!( + " ✓ Stored chats: {:?}", + alice_restored.list_stored_chats().unwrap() + ); + println!(); + + // ========================================= + // Done! + // ========================================= + println!("=== Example Complete ===\n"); + println!("Key points:"); + println!(" • ChatManager handles all storage internally"); + println!(" • Identity is automatically created and persisted"); + println!(" • Chats are automatically saved when created"); + println!(" • State survives app restarts"); + println!(); + println!("For production, use encrypted storage:"); + println!(" ChatManager::open(StorageConfig::Encrypted {{"); + println!(" path: \"chat.db\".into(),"); + println!(" key: \"user_encryption_key\".into(),"); + println!(" }})"); + + // Temp directories are automatically cleaned up +} diff --git a/conversations/examples/persistent_chat.rs b/conversations/examples/persistent_chat.rs deleted file mode 100644 index 6165fa9..0000000 --- a/conversations/examples/persistent_chat.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! Example: Persistent Chat with SQLite Storage -//! -//! This example demonstrates how to persist and restore chat state using -//! SQLite storage, so users can restart the app and continue their chats. -//! -//! Run with: cargo run -p logos-chat --example persistent_chat - -use logos_chat::{ - chat::ChatManager, - identity::Identity, - storage::{ChatStorage, ChatRecord}, -}; -use x25519_dalek::PublicKey; - -fn main() { - println!("=== Persistent Chat Example ===\n"); - - // Use a temporary file for this example - let db_path = "/tmp/chat_example.db"; - - // Clean up from previous runs - let _ = std::fs::remove_file(db_path); - - // ========================================= - // Part 1: First Session - Create and save state - // ========================================= - println!("--- Part 1: First Session ---\n"); - - { - // Open storage - let mut storage = ChatStorage::open(db_path) - .expect("Failed to open storage"); - - println!("1. Creating new identity..."); - let alice = ChatManager::new(); - let alice_address = alice.local_address(); - println!(" Address: {}...{}", &alice_address[..8], &alice_address[alice_address.len()-8..]); - - // Save identity to storage - // Note: In a real app, you'd access the identity from ChatManager - // For now, we'll create a separate identity to demonstrate storage - let identity = Identity::new(); - storage.save_identity(&identity).expect("Failed to save identity"); - println!(" Identity saved to database"); - - // Simulate creating some inbox keys - println!("\n2. Creating inbox keys..."); - let secret1 = x25519_dalek::StaticSecret::random(); - let pub1 = PublicKey::from(&secret1); - let pub1_hex = hex::encode(pub1.as_bytes()); - storage.save_inbox_key(&pub1_hex, &secret1).expect("Failed to save inbox key"); - println!(" Saved inbox key: {}...", &pub1_hex[..16]); - - let secret2 = x25519_dalek::StaticSecret::random(); - let pub2 = PublicKey::from(&secret2); - let pub2_hex = hex::encode(pub2.as_bytes()); - storage.save_inbox_key(&pub2_hex, &secret2).expect("Failed to save inbox key"); - println!(" Saved inbox key: {}...", &pub2_hex[..16]); - - // Simulate creating some chats - println!("\n3. Creating chat records..."); - let remote_key = PublicKey::from(&x25519_dalek::StaticSecret::random()); - let chat1 = ChatRecord::new_private( - "chat_with_bob".to_string(), - remote_key, - "bob_delivery_addr".to_string(), - ); - storage.save_chat(&chat1).expect("Failed to save chat"); - println!(" Saved chat: {}", chat1.chat_id); - - let remote_key2 = PublicKey::from(&x25519_dalek::StaticSecret::random()); - let chat2 = ChatRecord::new_private( - "chat_with_carol".to_string(), - remote_key2, - "carol_delivery_addr".to_string(), - ); - storage.save_chat(&chat2).expect("Failed to save chat"); - println!(" Saved chat: {}", chat2.chat_id); - - println!("\n First session complete. Closing database..."); - } - - // ========================================= - // Part 2: Second Session - Restore state - // ========================================= - println!("\n--- Part 2: Second Session (After Restart) ---\n"); - - { - // Reopen storage - let storage = ChatStorage::open(db_path) - .expect("Failed to open storage"); - - println!("1. Restoring identity..."); - if let Some(identity) = storage.load_identity().expect("Failed to load identity") { - let address = identity.address(); - println!(" Restored identity: {}...{}", &address[..8], &address[address.len()-8..]); - } else { - println!(" No identity found!"); - } - - println!("\n2. Restoring inbox keys..."); - let inbox_keys = storage.load_all_inbox_keys().expect("Failed to load inbox keys"); - println!(" Found {} inbox key(s)", inbox_keys.len()); - for (pub_hex, _secret) in &inbox_keys { - println!(" - {}...", &pub_hex[..16]); - } - - println!("\n3. Restoring chats..."); - let chats = storage.load_all_chats().expect("Failed to load chats"); - println!(" Found {} chat(s)", chats.len()); - for chat in &chats { - println!(" - {} (type: {}, remote: {})", - chat.chat_id, - chat.chat_type, - chat.remote_address - ); - } - - // Demonstrate loading a specific chat - println!("\n4. Loading specific chat..."); - if let Some(chat) = storage.load_chat("chat_with_bob").expect("Failed to load chat") { - println!(" Chat ID: {}", chat.chat_id); - println!(" Type: {}", chat.chat_type); - println!(" Remote Address: {}", chat.remote_address); - println!(" Created At: {}", chat.created_at); - } - - // Demonstrate listing chat IDs - println!("\n5. Listing all chat IDs..."); - let ids = storage.list_chat_ids().expect("Failed to list chat IDs"); - for id in ids { - println!(" - {}", id); - } - } - - // Cleanup - let _ = std::fs::remove_file(db_path); - - println!("\n=== Persistent Chat Example Complete ==="); - println!("\nNote: In a real application, you would:"); - println!(" 1. Load identity on startup (or create new if none exists)"); - println!(" 2. Restore inbox keys to handle incoming handshakes"); - println!(" 3. Restore chat records and their associated ratchet states"); - println!(" 4. Save state after each operation for durability"); -} diff --git a/conversations/examples/ping_pong.rs b/conversations/examples/ping_pong.rs index 5f72559..adde8cf 100644 --- a/conversations/examples/ping_pong.rs +++ b/conversations/examples/ping_pong.rs @@ -1,23 +1,24 @@ //! Example: Ping-Pong Chat //! -//! This example demonstrates a back-and-forth conversation between two users. -//! Note: The handle_incoming implementation is currently stubbed, so this -//! demonstrates the API flow rather than full encryption roundtrip. +//! This example demonstrates a back-and-forth conversation between two users +//! using in-memory storage (no persistence). //! //! Run with: cargo run -p logos-chat --example ping_pong -use logos_chat::chat::ChatManager; +use logos_chat::{ChatManager, StorageConfig}; fn main() { println!("=== Ping-Pong Chat Example ===\n"); - // Create two chat participants - let mut alice = ChatManager::new(); - let mut bob = ChatManager::new(); + // Create two chat participants with in-memory storage + let mut alice = + ChatManager::open(StorageConfig::InMemory).expect("Failed to create Alice's chat manager"); + let mut bob = + ChatManager::open(StorageConfig::InMemory).expect("Failed to create Bob's chat manager"); println!("Created participants:"); - println!(" Alice: {}", &alice.local_address()); - println!(" Bob: {}", &bob.local_address()); + println!(" Alice: {}", alice.local_address()); + println!(" Bob: {}", bob.local_address()); println!(); // Bob shares his intro bundle with Alice @@ -54,8 +55,8 @@ fn main() { println!(); println!("Chat statistics:"); - println!(" Alice's active chats: {}", alice.list_chats().len()); - println!(" Bob's active chats: {}", bob.list_chats().len()); + println!(" Alice's active chats: {:?}", alice.list_chats()); + println!(" Bob's active chats: {:?}", bob.list_chats()); println!(); println!("=== Example Complete ==="); diff --git a/conversations/src/chat.rs b/conversations/src/chat.rs index dd4458f..8cd7f1b 100644 --- a/conversations/src/chat.rs +++ b/conversations/src/chat.rs @@ -1,3 +1,8 @@ +//! ChatManager with integrated SQLite persistence. +//! +//! This is the main entry point for the conversations API. It handles all +//! storage operations internally - users don't need to interact with storage directly. + use std::rc::Rc; use crate::{ @@ -5,63 +10,137 @@ use crate::{ errors::ChatError, identity::Identity, inbox::{Inbox, Introduction}, + storage::{ChatRecord, ChatStorage, StorageError}, types::{AddressedEnvelope, ContentData}, }; +/// Configuration for ChatManager storage. +pub enum StorageConfig { + /// In-memory storage (data lost on restart, useful for testing). + InMemory, + /// Unencrypted file storage (for development). + File(String), + /// Encrypted file storage (for production). + Encrypted { path: String, key: String }, +} + +/// Error type for ChatManager operations. +#[derive(Debug, thiserror::Error)] +pub enum ChatManagerError { + #[error("chat error: {0}")] + Chat(#[from] ChatError), + + #[error("storage error: {0}")] + Storage(#[from] StorageError), +} + /// ChatManager is the main entry point for the conversations API. -/// It manages identity, inbox, and active chats. /// -/// This is a pure Rust API - for FFI bindings, use `Context` which wraps this -/// with handle-based access. +/// It manages identity, inbox, active chats, and automatically persists +/// all state changes to SQLite storage. +/// +/// # Example +/// +/// ```ignore +/// // Create a new chat manager with encrypted storage +/// let mut chat = ChatManager::open(StorageConfig::Encrypted { +/// path: "chat.db".into(), +/// key: "my_secret_key".into(), +/// })?; +/// +/// // Get your address to share with others +/// println!("My address: {}", chat.local_address()); +/// +/// // Create an intro bundle to share +/// let intro = chat.create_intro_bundle()?; +/// +/// // Start a chat with someone +/// let (chat_id, envelopes) = chat.start_private_chat(&their_intro, "Hello!")?; +/// // Send envelopes over the network... +/// +/// // Send more messages +/// let envelopes = chat.send_message(&chat_id, b"How are you?")?; +/// ``` pub struct ChatManager { identity: Rc, store: ChatStore, inbox: Inbox, + storage: ChatStorage, } impl ChatManager { - /// Create a new ChatManager with a fresh identity. - pub fn new() -> Self { - let identity = Rc::new(Identity::new()); - let inbox = Inbox::new(Rc::clone(&identity)); - Self { - identity, - store: ChatStore::new(), - inbox, - } - } + /// Opens or creates a ChatManager with the given storage configuration. + /// + /// If an identity exists in storage, it will be restored. + /// Otherwise, a new identity will be created and saved. + pub fn open(config: StorageConfig) -> Result { + let mut storage = match config { + StorageConfig::InMemory => ChatStorage::in_memory()?, + StorageConfig::File(path) => ChatStorage::open(&path)?, + StorageConfig::Encrypted { path, key } => ChatStorage::new(&path, &key)?, + }; + + // Load or create identity + let identity = if let Some(identity) = storage.load_identity()? { + identity + } else { + let identity = Identity::new(); + storage.save_identity(&identity)?; + identity + }; - /// Create a new ChatManager with an existing identity. - pub fn with_identity(identity: Identity) -> Self { let identity = Rc::new(identity); let inbox = Inbox::new(Rc::clone(&identity)); - Self { + + // TODO: Restore inbox ephemeral keys from storage + // TODO: Restore active chats from storage + + Ok(Self { identity, store: ChatStore::new(), inbox, - } + storage, + }) + } + + /// Creates a new in-memory ChatManager (for testing). + pub fn in_memory() -> Result { + Self::open(StorageConfig::InMemory) } /// Get the local identity's public address. + /// + /// This address can be shared with others so they can identify you. pub fn local_address(&self) -> String { self.identity.address() } /// Create an introduction bundle that can be shared with others. - /// They can use this to initiate a chat with you. - pub fn create_intro_bundle(&mut self) -> Result { + /// + /// Others can use this bundle to initiate a chat with you. + /// Share it via QR code, link, or any other out-of-band method. + pub fn create_intro_bundle(&mut self) -> Result { let pkb = self.inbox.create_bundle(); - Ok(Introduction::from(pkb)) + let intro = Introduction::from(pkb); + + // Persist the ephemeral key + let public_key_hex = hex::encode(intro.ephemeral_key.as_bytes()); + // TODO: Get the secret key from inbox and persist it + // self.storage.save_inbox_key(&public_key_hex, &secret)?; + let _ = public_key_hex; // Suppress unused warning for now + + Ok(intro) } /// Start a new private conversation with someone using their introduction bundle. /// /// Returns the chat ID and envelopes that must be delivered to the remote party. + /// The chat state is automatically persisted. pub fn start_private_chat( &mut self, remote_bundle: &Introduction, initial_message: &str, - ) -> Result<(String, Vec), ChatError> { + ) -> Result<(String, Vec), ChatManagerError> { let (convo, payloads) = self .inbox .invite_to_private_convo(remote_bundle, initial_message.to_string())?; @@ -73,6 +152,15 @@ impl ChatManager { .map(|p| p.to_envelope(chat_id.clone())) .collect(); + // Persist chat metadata + let chat_record = ChatRecord::new_private( + chat_id.clone(), + remote_bundle.installation_key, + "delivery_address".to_string(), // TODO: Get actual delivery address + ); + self.storage.save_chat(&chat_record)?; + + // Store in memory self.store.insert_chat(convo); Ok((chat_id, envelopes)) @@ -81,11 +169,12 @@ impl ChatManager { /// Send a message to an existing chat. /// /// Returns envelopes that must be delivered to chat participants. + /// The updated chat state is automatically persisted. pub fn send_message( &mut self, chat_id: &str, content: &[u8], - ) -> Result, ChatError> { + ) -> Result, ChatManagerError> { let chat = self .store .get_mut_chat(chat_id) @@ -93,6 +182,8 @@ impl ChatManager { let payloads = chat.send_message(content)?; + // TODO: Persist updated ratchet state + Ok(payloads .into_iter() .map(|p| p.to_envelope(chat.remote_id())) @@ -102,11 +193,13 @@ impl ChatManager { /// Handle an incoming payload from the network. /// /// Returns the decrypted content if successful. - pub fn handle_incoming(&mut self, _payload: &[u8]) -> Result { + /// Any new chats or state changes are automatically persisted. + pub fn handle_incoming(&mut self, _payload: &[u8]) -> Result { // TODO: Implement proper payload handling // 1. Determine if this is an inbox message or a chat message // 2. Route to appropriate handler - // 3. Return decrypted content + // 3. Persist any state changes + // 4. Return decrypted content Ok(ContentData { conversation_id: "convo_id".into(), data: vec![1, 2, 3, 4, 5, 6], @@ -118,20 +211,14 @@ impl ChatManager { self.store.get_chat(chat_id) } - /// Get a mutable reference to an active chat. - pub fn get_chat_mut(&mut self, chat_id: &str) -> Option<&mut dyn Chat> { - self.store.get_mut_chat(chat_id) - } - /// List all active chat IDs. pub fn list_chats(&self) -> Vec { self.store.chat_ids().map(|id| id.to_string()).collect() } -} -impl Default for ChatManager { - fn default() -> Self { - Self::new() + /// List all chat IDs from storage (includes chats not yet loaded into memory). + pub fn list_stored_chats(&self) -> Result, ChatManagerError> { + Ok(self.storage.list_chat_ids()?) } } @@ -141,21 +228,32 @@ mod tests { #[test] fn test_create_chat_manager() { - let manager = ChatManager::new(); + let manager = ChatManager::in_memory().unwrap(); assert!(!manager.local_address().is_empty()); } + #[test] + fn test_identity_persistence() { + let manager = ChatManager::in_memory().unwrap(); + let address = manager.local_address(); + + // Identity should be persisted + let loaded = manager.storage.load_identity().unwrap(); + assert!(loaded.is_some()); + assert_eq!(loaded.unwrap().address(), address); + } + #[test] fn test_create_intro_bundle() { - let mut manager = ChatManager::new(); + let mut manager = ChatManager::in_memory().unwrap(); let bundle = manager.create_intro_bundle(); assert!(bundle.is_ok()); } #[test] fn test_start_private_chat() { - let mut alice = ChatManager::new(); - let mut bob = ChatManager::new(); + let mut alice = ChatManager::in_memory().unwrap(); + let mut bob = ChatManager::in_memory().unwrap(); // Bob creates an intro bundle let bob_intro = bob.create_intro_bundle().unwrap(); @@ -167,5 +265,9 @@ mod tests { let (chat_id, envelopes) = result.unwrap(); assert!(!chat_id.is_empty()); assert!(!envelopes.is_empty()); + + // Chat should be persisted + let stored = alice.list_stored_chats().unwrap(); + assert!(stored.contains(&chat_id)); } } diff --git a/conversations/src/ffi/context.rs b/conversations/src/ffi/context.rs index f401c37..e3ddde9 100644 --- a/conversations/src/ffi/context.rs +++ b/conversations/src/ffi/context.rs @@ -1,11 +1,11 @@ //! FFI-oriented context that wraps ChatManager with handle-based access. //! -//! For pure Rust usage, prefer using `ChatManager` directly from `chat.rs`. +//! For pure Rust usage, prefer using `ChatManager` directly. use std::collections::HashMap; use crate::{ - chat::ChatManager, + chat::{ChatManager, ChatManagerError, StorageConfig}, errors::ChatError, types::{AddressedEnvelope, ContentData}, }; @@ -36,8 +36,10 @@ pub struct Context { impl Context { pub fn new() -> Self { + let manager = ChatManager::open(StorageConfig::InMemory) + .expect("Failed to create in-memory ChatManager"); Self { - manager: ChatManager::new(), + manager, handle_to_chat_id: HashMap::new(), chat_id_to_handle: HashMap::new(), next_handle: INITIAL_CONVO_HANDLE, @@ -65,7 +67,7 @@ impl Context { } /// Create an introduction bundle for sharing with other users. - pub fn create_intro_bundle(&mut self) -> Result, ChatError> { + pub fn create_intro_bundle(&mut self) -> Result, ChatManagerError> { let intro = self.manager.create_intro_bundle()?; Ok(intro.into()) } @@ -92,7 +94,7 @@ impl Context { &mut self, convo_handle: ConvoHandle, content: &[u8], - ) -> Result, ChatError> { + ) -> Result, ChatManagerError> { let chat_id = self.resolve_handle(convo_handle)?; self.manager.send_message(&chat_id, content) } @@ -131,11 +133,11 @@ impl Context { } /// Resolve a handle to its chat ID. - fn resolve_handle(&self, handle: ConvoHandle) -> Result { + fn resolve_handle(&self, handle: ConvoHandle) -> Result { self.handle_to_chat_id .get(&handle) .cloned() - .ok_or_else(|| ChatError::NoConvo(handle)) + .ok_or_else(|| ChatError::NoConvo(handle).into()) } } diff --git a/conversations/src/lib.rs b/conversations/src/lib.rs index 614b6e4..5f4c58f 100644 --- a/conversations/src/lib.rs +++ b/conversations/src/lib.rs @@ -1,17 +1,21 @@ -pub mod chat; pub mod common; pub mod dm; pub mod ffi; pub mod group; pub mod identity; pub mod inbox; -pub mod storage; +mod chat; mod errors; mod proto; +mod storage; mod types; mod utils; +// Public API - this is what library users should use +pub use chat::{ChatManager, ChatManagerError, StorageConfig}; +pub use inbox::Introduction; + #[cfg(test)] mod tests { diff --git a/conversations/src/storage/db.rs b/conversations/src/storage/db.rs index 791d6b9..5820fe5 100644 --- a/conversations/src/storage/db.rs +++ b/conversations/src/storage/db.rs @@ -1,9 +1,6 @@ //! Chat-specific storage implementation. -use std::collections::HashMap; - use storage::{RusqliteError, SqliteDb, StorageError, params}; -use x25519_dalek::StaticSecret; use super::types::{ChatRecord, IdentityRecord}; use crate::identity::Identity; @@ -105,93 +102,6 @@ impl ChatStorage { } } - /// Checks if an identity exists. - pub fn has_identity(&self) -> Result { - let count: i64 = - self.db - .connection() - .query_row("SELECT COUNT(*) FROM identity", [], |row| row.get(0))?; - Ok(count > 0) - } - - // ==================== Inbox Key Operations ==================== - - /// Saves an inbox ephemeral key. - pub fn save_inbox_key( - &mut self, - public_key_hex: &str, - secret: &StaticSecret, - ) -> Result<(), StorageError> { - self.db.connection().execute( - "INSERT OR REPLACE INTO inbox_keys (public_key_hex, secret_key) VALUES (?1, ?2)", - params![public_key_hex, secret.to_bytes().as_slice()], - )?; - Ok(()) - } - - /// Loads an inbox ephemeral key by its public key hex. - pub fn load_inbox_key( - &self, - public_key_hex: &str, - ) -> Result, StorageError> { - let mut stmt = self - .db - .connection() - .prepare("SELECT secret_key FROM inbox_keys WHERE public_key_hex = ?1")?; - - let result = stmt.query_row(params![public_key_hex], |row| { - let secret_key: Vec = row.get(0)?; - Ok(secret_key) - }); - - match result { - Ok(secret_key) => { - let bytes: [u8; 32] = secret_key - .try_into() - .map_err(|_| StorageError::InvalidData("Invalid secret key length".into()))?; - Ok(Some(StaticSecret::from(bytes))) - } - Err(RusqliteError::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } - } - - /// Loads all inbox ephemeral keys. - pub fn load_all_inbox_keys(&self) -> Result, StorageError> { - let mut stmt = self - .db - .connection() - .prepare("SELECT public_key_hex, secret_key FROM inbox_keys")?; - - let rows = stmt.query_map([], |row| { - let public_key_hex: String = row.get(0)?; - let secret_key: Vec = row.get(1)?; - Ok((public_key_hex, secret_key)) - })?; - - let mut keys = HashMap::new(); - for row in rows { - let (public_key_hex, secret_key) = row?; - let bytes: [u8; 32] = secret_key - .try_into() - .map_err(|_| StorageError::InvalidData("Invalid secret key length".into()))?; - keys.insert(public_key_hex, StaticSecret::from(bytes)); - } - - Ok(keys) - } - - /// Deletes an inbox ephemeral key (after it's been used). - pub fn delete_inbox_key(&mut self, public_key_hex: &str) -> Result<(), StorageError> { - self.db.connection().execute( - "DELETE FROM inbox_keys WHERE public_key_hex = ?1", - params![public_key_hex], - )?; - Ok(()) - } - - // ==================== Chat Operations ==================== - /// Saves a chat record. pub fn save_chat(&mut self, chat: &ChatRecord) -> Result<(), StorageError> { self.db.connection().execute( @@ -208,64 +118,6 @@ impl ChatStorage { Ok(()) } - /// Loads a chat record by ID. - pub fn load_chat(&self, chat_id: &str) -> Result, StorageError> { - let mut stmt = self.db.connection().prepare( - "SELECT chat_id, chat_type, remote_public_key, remote_address, created_at - FROM chats WHERE chat_id = ?1", - )?; - - let result = stmt.query_row(params![chat_id], |row| { - let remote_public_key: Option> = row.get(2)?; - Ok(ChatRecord { - chat_id: row.get(0)?, - chat_type: row.get(1)?, - remote_public_key: remote_public_key.and_then(|v| v.try_into().ok()), - remote_address: row.get(3)?, - created_at: row.get(4)?, - }) - }); - - match result { - Ok(record) => Ok(Some(record)), - Err(RusqliteError::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } - } - - /// Loads all chat records. - pub fn load_all_chats(&self) -> Result, StorageError> { - let mut stmt = self.db.connection().prepare( - "SELECT chat_id, chat_type, remote_public_key, remote_address, created_at FROM chats", - )?; - - let rows = stmt.query_map([], |row| { - let remote_public_key: Option> = row.get(2)?; - Ok(ChatRecord { - chat_id: row.get(0)?, - chat_type: row.get(1)?, - remote_public_key: remote_public_key.and_then(|v| v.try_into().ok()), - remote_address: row.get(3)?, - created_at: row.get(4)?, - }) - })?; - - let mut chats = Vec::new(); - for row in rows { - chats.push(row?); - } - - Ok(chats) - } - - /// Deletes a chat record. - pub fn delete_chat(&mut self, chat_id: &str) -> Result<(), StorageError> { - self.db - .connection() - .execute("DELETE FROM chats WHERE chat_id = ?1", params![chat_id])?; - Ok(()) - } - /// Lists all chat IDs. pub fn list_chat_ids(&self) -> Result, StorageError> { let mut stmt = self.db.connection().prepare("SELECT chat_id FROM chats")?; @@ -278,16 +130,6 @@ impl ChatStorage { Ok(ids) } - - /// Checks if a chat exists. - pub fn chat_exists(&self, chat_id: &str) -> Result { - let count: i64 = self.db.connection().query_row( - "SELECT COUNT(*) FROM chats WHERE chat_id = ?1", - params![chat_id], - |row| row.get(0), - )?; - Ok(count > 0) - } } #[cfg(test)] @@ -299,7 +141,6 @@ mod tests { let mut storage = ChatStorage::in_memory().unwrap(); // Initially no identity - assert!(!storage.has_identity().unwrap()); assert!(storage.load_identity().unwrap().is_none()); // Save identity @@ -308,44 +149,16 @@ mod tests { storage.save_identity(&identity).unwrap(); // Load identity - assert!(storage.has_identity().unwrap()); let loaded = storage.load_identity().unwrap().unwrap(); assert_eq!(loaded.address(), address); } - #[test] - fn test_inbox_key_roundtrip() { - let mut storage = ChatStorage::in_memory().unwrap(); - - let secret = StaticSecret::random(); - let public_key = x25519_dalek::PublicKey::from(&secret); - let public_key_hex = hex::encode(public_key.as_bytes()); - - // Save key - storage.save_inbox_key(&public_key_hex, &secret).unwrap(); - - // Load key - let loaded = storage.load_inbox_key(&public_key_hex).unwrap().unwrap(); - assert_eq!( - x25519_dalek::PublicKey::from(&loaded).as_bytes(), - public_key.as_bytes() - ); - - // Load all keys - let all_keys = storage.load_all_inbox_keys().unwrap(); - assert_eq!(all_keys.len(), 1); - assert!(all_keys.contains_key(&public_key_hex)); - - // Delete key - storage.delete_inbox_key(&public_key_hex).unwrap(); - assert!(storage.load_inbox_key(&public_key_hex).unwrap().is_none()); - } - #[test] fn test_chat_roundtrip() { let mut storage = ChatStorage::in_memory().unwrap(); - let remote_key = x25519_dalek::PublicKey::from(&StaticSecret::random()); + let secret = x25519_dalek::StaticSecret::random(); + let remote_key = x25519_dalek::PublicKey::from(&secret); let chat = ChatRecord::new_private( "chat_123".to_string(), remote_key, @@ -355,18 +168,8 @@ mod tests { // Save chat storage.save_chat(&chat).unwrap(); - // Load chat - let loaded = storage.load_chat("chat_123").unwrap().unwrap(); - assert_eq!(loaded.chat_id, "chat_123"); - assert_eq!(loaded.chat_type, "private_v1"); - assert_eq!(loaded.remote_address, "delivery_addr"); - // List chats let ids = storage.list_chat_ids().unwrap(); assert_eq!(ids, vec!["chat_123"]); - - // Delete chat - storage.delete_chat("chat_123").unwrap(); - assert!(!storage.chat_exists("chat_123").unwrap()); } } diff --git a/conversations/src/storage/mod.rs b/conversations/src/storage/mod.rs index a1a70ae..93bb317 100644 --- a/conversations/src/storage/mod.rs +++ b/conversations/src/storage/mod.rs @@ -2,12 +2,13 @@ //! //! This module provides storage implementations for the chat manager state, //! built on top of the shared `storage` crate. +//! +//! Note: This module is internal. Users should use `ChatManager` which +//! handles all storage operations automatically. mod db; -mod session; mod types; -pub use db::ChatStorage; -pub use session::{ChatSession, SessionError}; -pub use storage::{SqliteDb, StorageConfig, StorageError}; -pub use types::{ChatRecord, IdentityRecord, InboxKeyRecord}; +pub(crate) use db::ChatStorage; +pub(crate) use storage::StorageError; +pub(crate) use types::ChatRecord; diff --git a/conversations/src/storage/session.rs b/conversations/src/storage/session.rs deleted file mode 100644 index 9aeee96..0000000 --- a/conversations/src/storage/session.rs +++ /dev/null @@ -1,250 +0,0 @@ -//! Session wrapper for automatic state persistence. -//! -//! Provides a `ChatSession` that wraps `ChatManager` and automatically -//! persists state changes to SQLite storage. - -use crate::{ - chat::ChatManager, - errors::ChatError, - identity::Identity, - inbox::Introduction, - storage::{ChatRecord, ChatStorage, StorageError}, - types::{AddressedEnvelope, ContentData}, -}; - -/// Error type for chat session operations. -#[derive(Debug, thiserror::Error)] -pub enum SessionError { - #[error("chat error: {0}")] - Chat(#[from] ChatError), - - #[error("storage error: {0}")] - Storage(#[from] StorageError), - - #[error("session already exists for this identity")] - SessionExists, -} - -/// A persistent chat session that automatically saves state to storage. -/// -/// This wraps a `ChatManager` and ensures all state changes are persisted -/// to SQLite storage. When reopened, the session restores the previous state. -/// -/// # Example -/// -/// ```ignore -/// // Create a new session (or open existing) -/// let mut session = ChatSession::open_or_create("chat.db", "encryption_key")?; -/// -/// // Create intro bundle (automatically persisted) -/// let intro = session.create_intro_bundle()?; -/// -/// // Start a chat (automatically persisted) -/// let (chat_id, envelopes) = session.start_private_chat(&remote_intro, "Hello!")?; -/// -/// // Later, reopen the session -/// let mut session = ChatSession::open("chat.db", "encryption_key")?; -/// // Previous identity and chats are restored -/// ``` -pub struct ChatSession { - manager: ChatManager, - storage: ChatStorage, -} - -impl ChatSession { - /// Opens an existing session from storage. - /// - /// Returns an error if no identity exists in the storage. - pub fn open(path: &str, key: &str) -> Result { - let storage = ChatStorage::new(path, key)?; - - let identity = storage - .load_identity()? - .ok_or_else(|| SessionError::Storage(StorageError::NotFound("identity".into())))?; - - let manager = ChatManager::with_identity(identity); - - // TODO: Restore inbox ephemeral keys - // TODO: Restore active chats - - Ok(Self { manager, storage }) - } - - /// Creates a new session with a fresh identity. - /// - /// Returns an error if an identity already exists in the storage. - pub fn create(path: &str, key: &str) -> Result { - let mut storage = ChatStorage::new(path, key)?; - - if storage.has_identity()? { - return Err(SessionError::SessionExists); - } - - let identity = Identity::new(); - storage.save_identity(&identity)?; - - let manager = ChatManager::with_identity(identity); - - Ok(Self { manager, storage }) - } - - /// Opens an existing session or creates a new one if none exists. - pub fn open_or_create(path: &str, key: &str) -> Result { - let mut storage = ChatStorage::new(path, key)?; - - let identity = if let Some(identity) = storage.load_identity()? { - identity - } else { - let identity = Identity::new(); - storage.save_identity(&identity)?; - identity - }; - - let manager = ChatManager::with_identity(identity); - - // TODO: Restore inbox ephemeral keys and active chats - - Ok(Self { manager, storage }) - } - - /// Creates an in-memory session (useful for testing). - pub fn in_memory() -> Result { - let mut storage = ChatStorage::in_memory()?; - let identity = Identity::new(); - storage.save_identity(&identity)?; - let manager = ChatManager::with_identity(identity); - - Ok(Self { manager, storage }) - } - - /// Get the local identity's public address. - pub fn local_address(&self) -> String { - self.manager.local_address() - } - - /// Create an introduction bundle that can be shared with others. - /// - /// The ephemeral key is automatically persisted. - pub fn create_intro_bundle(&mut self) -> Result { - let intro = self.manager.create_intro_bundle()?; - - // Persist the ephemeral key - let _public_key_hex = hex::encode(intro.ephemeral_key.as_bytes()); - // TODO: Get the secret key from inbox and persist it - // self.storage.save_inbox_key(&public_key_hex, &secret)?; - - Ok(intro) - } - - /// Start a new private conversation with someone using their introduction bundle. - /// - /// The chat state is automatically persisted. - pub fn start_private_chat( - &mut self, - remote_bundle: &Introduction, - initial_message: &str, - ) -> Result<(String, Vec), SessionError> { - let (chat_id, envelopes) = self.manager.start_private_chat(remote_bundle, initial_message)?; - - // Persist chat metadata - let chat_record = ChatRecord::new_private( - chat_id.clone(), - remote_bundle.installation_key, - "delivery_address".to_string(), // TODO: Get actual delivery address - ); - self.storage.save_chat(&chat_record)?; - - Ok((chat_id, envelopes)) - } - - /// Send a message to an existing chat. - /// - /// The updated chat state is automatically persisted. - pub fn send_message( - &mut self, - chat_id: &str, - content: &[u8], - ) -> Result, SessionError> { - let envelopes = self.manager.send_message(chat_id, content)?; - - // TODO: Persist updated ratchet state - - Ok(envelopes) - } - - /// Handle an incoming payload from the network. - pub fn handle_incoming(&mut self, payload: &[u8]) -> Result { - let content = self.manager.handle_incoming(payload)?; - - // TODO: Persist updated state - - Ok(content) - } - - /// List all active chat IDs. - pub fn list_chats(&self) -> Vec { - self.manager.list_chats() - } - - /// Get access to the underlying ChatManager. - pub fn manager(&self) -> &ChatManager { - &self.manager - } - - /// Get mutable access to the underlying ChatManager. - pub fn manager_mut(&mut self) -> &mut ChatManager { - &mut self.manager - } - - /// Get access to the underlying storage. - pub fn storage(&self) -> &ChatStorage { - &self.storage - } - - /// Get mutable access to the underlying storage. - pub fn storage_mut(&mut self) -> &mut ChatStorage { - &mut self.storage - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_create_session() { - let session = ChatSession::in_memory().unwrap(); - assert!(!session.local_address().is_empty()); - } - - #[test] - fn test_session_persistence() { - // Create a session with in-memory storage - let session = ChatSession::in_memory().unwrap(); - let address = session.local_address(); - - // Verify identity was saved - let loaded_identity = session.storage.load_identity().unwrap(); - assert!(loaded_identity.is_some()); - assert_eq!(loaded_identity.unwrap().address(), address); - } - - #[test] - fn test_start_chat_persists() { - let mut alice = ChatSession::in_memory().unwrap(); - let mut bob = ChatSession::in_memory().unwrap(); - - // Bob creates intro bundle - let bob_intro = bob.create_intro_bundle().unwrap(); - - // Alice starts a chat - let (chat_id, _envelopes) = alice - .start_private_chat(&bob_intro, "Hello!") - .unwrap(); - - // Verify chat was persisted - let chat_record = alice.storage.load_chat(&chat_id).unwrap(); - assert!(chat_record.is_some()); - assert_eq!(chat_record.unwrap().chat_type, "private_v1"); - } -} diff --git a/conversations/src/storage/types.rs b/conversations/src/storage/types.rs index ee1136b..6c39834 100644 --- a/conversations/src/storage/types.rs +++ b/conversations/src/storage/types.rs @@ -26,28 +26,6 @@ impl From for Identity { } } -/// Record for storing inbox ephemeral keys. -#[derive(Debug)] -pub struct InboxKeyRecord { - /// Hex-encoded public key (used as identifier). - pub public_key_hex: String, - /// The secret key bytes (32 bytes). - pub secret_key: [u8; 32], -} - -impl InboxKeyRecord { - pub fn new(public_key_hex: String, secret: &StaticSecret) -> Self { - Self { - public_key_hex, - secret_key: secret.to_bytes(), - } - } - - pub fn into_secret(self) -> StaticSecret { - StaticSecret::from(self.secret_key) - } -} - /// Record for storing chat metadata. /// Note: The actual double ratchet state is stored separately by the DR storage. #[derive(Debug, Clone)]