mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-02-10 17:03:12 +00:00
chore: further remove session
This commit is contained in:
parent
a14079807e
commit
b2a1ca647b
@ -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
|
|
||||||
}
|
|
||||||
135
conversations/examples/persist_chat.rs
Normal file
135
conversations/examples/persist_chat.rs
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -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");
|
|
||||||
}
|
|
||||||
@ -1,23 +1,24 @@
|
|||||||
//! Example: Ping-Pong Chat
|
//! Example: Ping-Pong Chat
|
||||||
//!
|
//!
|
||||||
//! This example demonstrates a back-and-forth conversation between two users.
|
//! This example demonstrates a back-and-forth conversation between two users
|
||||||
//! Note: The handle_incoming implementation is currently stubbed, so this
|
//! using in-memory storage (no persistence).
|
||||||
//! demonstrates the API flow rather than full encryption roundtrip.
|
|
||||||
//!
|
//!
|
||||||
//! Run with: cargo run -p logos-chat --example ping_pong
|
//! Run with: cargo run -p logos-chat --example ping_pong
|
||||||
|
|
||||||
use logos_chat::chat::ChatManager;
|
use logos_chat::{ChatManager, StorageConfig};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
println!("=== Ping-Pong Chat Example ===\n");
|
println!("=== Ping-Pong Chat Example ===\n");
|
||||||
|
|
||||||
// Create two chat participants
|
// Create two chat participants with in-memory storage
|
||||||
let mut alice = ChatManager::new();
|
let mut alice =
|
||||||
let mut bob = ChatManager::new();
|
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!("Created participants:");
|
||||||
println!(" Alice: {}", &alice.local_address());
|
println!(" Alice: {}", alice.local_address());
|
||||||
println!(" Bob: {}", &bob.local_address());
|
println!(" Bob: {}", bob.local_address());
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
// Bob shares his intro bundle with Alice
|
// Bob shares his intro bundle with Alice
|
||||||
@ -54,8 +55,8 @@ fn main() {
|
|||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!("Chat statistics:");
|
println!("Chat statistics:");
|
||||||
println!(" Alice's active chats: {}", alice.list_chats().len());
|
println!(" Alice's active chats: {:?}", alice.list_chats());
|
||||||
println!(" Bob's active chats: {}", bob.list_chats().len());
|
println!(" Bob's active chats: {:?}", bob.list_chats());
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
println!("=== Example Complete ===");
|
println!("=== Example Complete ===");
|
||||||
|
|||||||
@ -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 std::rc::Rc;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@ -5,63 +10,137 @@ use crate::{
|
|||||||
errors::ChatError,
|
errors::ChatError,
|
||||||
identity::Identity,
|
identity::Identity,
|
||||||
inbox::{Inbox, Introduction},
|
inbox::{Inbox, Introduction},
|
||||||
|
storage::{ChatRecord, ChatStorage, StorageError},
|
||||||
types::{AddressedEnvelope, ContentData},
|
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.
|
/// 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
|
/// It manages identity, inbox, active chats, and automatically persists
|
||||||
/// with handle-based access.
|
/// 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 {
|
pub struct ChatManager {
|
||||||
identity: Rc<Identity>,
|
identity: Rc<Identity>,
|
||||||
store: ChatStore,
|
store: ChatStore,
|
||||||
inbox: Inbox,
|
inbox: Inbox,
|
||||||
|
storage: ChatStorage,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatManager {
|
impl ChatManager {
|
||||||
/// Create a new ChatManager with a fresh identity.
|
/// Opens or creates a ChatManager with the given storage configuration.
|
||||||
pub fn new() -> Self {
|
///
|
||||||
let identity = Rc::new(Identity::new());
|
/// If an identity exists in storage, it will be restored.
|
||||||
let inbox = Inbox::new(Rc::clone(&identity));
|
/// Otherwise, a new identity will be created and saved.
|
||||||
Self {
|
pub fn open(config: StorageConfig) -> Result<Self, ChatManagerError> {
|
||||||
identity,
|
let mut storage = match config {
|
||||||
store: ChatStore::new(),
|
StorageConfig::InMemory => ChatStorage::in_memory()?,
|
||||||
inbox,
|
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 identity = Rc::new(identity);
|
||||||
let inbox = Inbox::new(Rc::clone(&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,
|
identity,
|
||||||
store: ChatStore::new(),
|
store: ChatStore::new(),
|
||||||
inbox,
|
inbox,
|
||||||
}
|
storage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new in-memory ChatManager (for testing).
|
||||||
|
pub fn in_memory() -> Result<Self, ChatManagerError> {
|
||||||
|
Self::open(StorageConfig::InMemory)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the local identity's public address.
|
/// 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 {
|
pub fn local_address(&self) -> String {
|
||||||
self.identity.address()
|
self.identity.address()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an introduction bundle that can be shared with others.
|
/// 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<Introduction, ChatError> {
|
/// 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<Introduction, ChatManagerError> {
|
||||||
let pkb = self.inbox.create_bundle();
|
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.
|
/// 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.
|
/// 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(
|
pub fn start_private_chat(
|
||||||
&mut self,
|
&mut self,
|
||||||
remote_bundle: &Introduction,
|
remote_bundle: &Introduction,
|
||||||
initial_message: &str,
|
initial_message: &str,
|
||||||
) -> Result<(String, Vec<AddressedEnvelope>), ChatError> {
|
) -> Result<(String, Vec<AddressedEnvelope>), ChatManagerError> {
|
||||||
let (convo, payloads) = self
|
let (convo, payloads) = self
|
||||||
.inbox
|
.inbox
|
||||||
.invite_to_private_convo(remote_bundle, initial_message.to_string())?;
|
.invite_to_private_convo(remote_bundle, initial_message.to_string())?;
|
||||||
@ -73,6 +152,15 @@ impl ChatManager {
|
|||||||
.map(|p| p.to_envelope(chat_id.clone()))
|
.map(|p| p.to_envelope(chat_id.clone()))
|
||||||
.collect();
|
.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);
|
self.store.insert_chat(convo);
|
||||||
|
|
||||||
Ok((chat_id, envelopes))
|
Ok((chat_id, envelopes))
|
||||||
@ -81,11 +169,12 @@ impl ChatManager {
|
|||||||
/// Send a message to an existing chat.
|
/// Send a message to an existing chat.
|
||||||
///
|
///
|
||||||
/// Returns envelopes that must be delivered to chat participants.
|
/// Returns envelopes that must be delivered to chat participants.
|
||||||
|
/// The updated chat state is automatically persisted.
|
||||||
pub fn send_message(
|
pub fn send_message(
|
||||||
&mut self,
|
&mut self,
|
||||||
chat_id: &str,
|
chat_id: &str,
|
||||||
content: &[u8],
|
content: &[u8],
|
||||||
) -> Result<Vec<AddressedEnvelope>, ChatError> {
|
) -> Result<Vec<AddressedEnvelope>, ChatManagerError> {
|
||||||
let chat = self
|
let chat = self
|
||||||
.store
|
.store
|
||||||
.get_mut_chat(chat_id)
|
.get_mut_chat(chat_id)
|
||||||
@ -93,6 +182,8 @@ impl ChatManager {
|
|||||||
|
|
||||||
let payloads = chat.send_message(content)?;
|
let payloads = chat.send_message(content)?;
|
||||||
|
|
||||||
|
// TODO: Persist updated ratchet state
|
||||||
|
|
||||||
Ok(payloads
|
Ok(payloads
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|p| p.to_envelope(chat.remote_id()))
|
.map(|p| p.to_envelope(chat.remote_id()))
|
||||||
@ -102,11 +193,13 @@ impl ChatManager {
|
|||||||
/// Handle an incoming payload from the network.
|
/// Handle an incoming payload from the network.
|
||||||
///
|
///
|
||||||
/// Returns the decrypted content if successful.
|
/// Returns the decrypted content if successful.
|
||||||
pub fn handle_incoming(&mut self, _payload: &[u8]) -> Result<ContentData, ChatError> {
|
/// Any new chats or state changes are automatically persisted.
|
||||||
|
pub fn handle_incoming(&mut self, _payload: &[u8]) -> Result<ContentData, ChatManagerError> {
|
||||||
// TODO: Implement proper payload handling
|
// TODO: Implement proper payload handling
|
||||||
// 1. Determine if this is an inbox message or a chat message
|
// 1. Determine if this is an inbox message or a chat message
|
||||||
// 2. Route to appropriate handler
|
// 2. Route to appropriate handler
|
||||||
// 3. Return decrypted content
|
// 3. Persist any state changes
|
||||||
|
// 4. Return decrypted content
|
||||||
Ok(ContentData {
|
Ok(ContentData {
|
||||||
conversation_id: "convo_id".into(),
|
conversation_id: "convo_id".into(),
|
||||||
data: vec![1, 2, 3, 4, 5, 6],
|
data: vec![1, 2, 3, 4, 5, 6],
|
||||||
@ -118,20 +211,14 @@ impl ChatManager {
|
|||||||
self.store.get_chat(chat_id)
|
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.
|
/// List all active chat IDs.
|
||||||
pub fn list_chats(&self) -> Vec<String> {
|
pub fn list_chats(&self) -> Vec<String> {
|
||||||
self.store.chat_ids().map(|id| id.to_string()).collect()
|
self.store.chat_ids().map(|id| id.to_string()).collect()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ChatManager {
|
/// List all chat IDs from storage (includes chats not yet loaded into memory).
|
||||||
fn default() -> Self {
|
pub fn list_stored_chats(&self) -> Result<Vec<String>, ChatManagerError> {
|
||||||
Self::new()
|
Ok(self.storage.list_chat_ids()?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,21 +228,32 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_create_chat_manager() {
|
fn test_create_chat_manager() {
|
||||||
let manager = ChatManager::new();
|
let manager = ChatManager::in_memory().unwrap();
|
||||||
assert!(!manager.local_address().is_empty());
|
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]
|
#[test]
|
||||||
fn test_create_intro_bundle() {
|
fn test_create_intro_bundle() {
|
||||||
let mut manager = ChatManager::new();
|
let mut manager = ChatManager::in_memory().unwrap();
|
||||||
let bundle = manager.create_intro_bundle();
|
let bundle = manager.create_intro_bundle();
|
||||||
assert!(bundle.is_ok());
|
assert!(bundle.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_start_private_chat() {
|
fn test_start_private_chat() {
|
||||||
let mut alice = ChatManager::new();
|
let mut alice = ChatManager::in_memory().unwrap();
|
||||||
let mut bob = ChatManager::new();
|
let mut bob = ChatManager::in_memory().unwrap();
|
||||||
|
|
||||||
// Bob creates an intro bundle
|
// Bob creates an intro bundle
|
||||||
let bob_intro = bob.create_intro_bundle().unwrap();
|
let bob_intro = bob.create_intro_bundle().unwrap();
|
||||||
@ -167,5 +265,9 @@ mod tests {
|
|||||||
let (chat_id, envelopes) = result.unwrap();
|
let (chat_id, envelopes) = result.unwrap();
|
||||||
assert!(!chat_id.is_empty());
|
assert!(!chat_id.is_empty());
|
||||||
assert!(!envelopes.is_empty());
|
assert!(!envelopes.is_empty());
|
||||||
|
|
||||||
|
// Chat should be persisted
|
||||||
|
let stored = alice.list_stored_chats().unwrap();
|
||||||
|
assert!(stored.contains(&chat_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
//! FFI-oriented context that wraps ChatManager with handle-based access.
|
//! 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 std::collections::HashMap;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
chat::ChatManager,
|
chat::{ChatManager, ChatManagerError, StorageConfig},
|
||||||
errors::ChatError,
|
errors::ChatError,
|
||||||
types::{AddressedEnvelope, ContentData},
|
types::{AddressedEnvelope, ContentData},
|
||||||
};
|
};
|
||||||
@ -36,8 +36,10 @@ pub struct Context {
|
|||||||
|
|
||||||
impl Context {
|
impl Context {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
|
let manager = ChatManager::open(StorageConfig::InMemory)
|
||||||
|
.expect("Failed to create in-memory ChatManager");
|
||||||
Self {
|
Self {
|
||||||
manager: ChatManager::new(),
|
manager,
|
||||||
handle_to_chat_id: HashMap::new(),
|
handle_to_chat_id: HashMap::new(),
|
||||||
chat_id_to_handle: HashMap::new(),
|
chat_id_to_handle: HashMap::new(),
|
||||||
next_handle: INITIAL_CONVO_HANDLE,
|
next_handle: INITIAL_CONVO_HANDLE,
|
||||||
@ -65,7 +67,7 @@ impl Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create an introduction bundle for sharing with other users.
|
/// Create an introduction bundle for sharing with other users.
|
||||||
pub fn create_intro_bundle(&mut self) -> Result<Vec<u8>, ChatError> {
|
pub fn create_intro_bundle(&mut self) -> Result<Vec<u8>, ChatManagerError> {
|
||||||
let intro = self.manager.create_intro_bundle()?;
|
let intro = self.manager.create_intro_bundle()?;
|
||||||
Ok(intro.into())
|
Ok(intro.into())
|
||||||
}
|
}
|
||||||
@ -92,7 +94,7 @@ impl Context {
|
|||||||
&mut self,
|
&mut self,
|
||||||
convo_handle: ConvoHandle,
|
convo_handle: ConvoHandle,
|
||||||
content: &[u8],
|
content: &[u8],
|
||||||
) -> Result<Vec<AddressedEnvelope>, ChatError> {
|
) -> Result<Vec<AddressedEnvelope>, ChatManagerError> {
|
||||||
let chat_id = self.resolve_handle(convo_handle)?;
|
let chat_id = self.resolve_handle(convo_handle)?;
|
||||||
self.manager.send_message(&chat_id, content)
|
self.manager.send_message(&chat_id, content)
|
||||||
}
|
}
|
||||||
@ -131,11 +133,11 @@ impl Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a handle to its chat ID.
|
/// Resolve a handle to its chat ID.
|
||||||
fn resolve_handle(&self, handle: ConvoHandle) -> Result<String, ChatError> {
|
fn resolve_handle(&self, handle: ConvoHandle) -> Result<String, ChatManagerError> {
|
||||||
self.handle_to_chat_id
|
self.handle_to_chat_id
|
||||||
.get(&handle)
|
.get(&handle)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| ChatError::NoConvo(handle))
|
.ok_or_else(|| ChatError::NoConvo(handle).into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +1,21 @@
|
|||||||
pub mod chat;
|
|
||||||
pub mod common;
|
pub mod common;
|
||||||
pub mod dm;
|
pub mod dm;
|
||||||
pub mod ffi;
|
pub mod ffi;
|
||||||
pub mod group;
|
pub mod group;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
pub mod inbox;
|
pub mod inbox;
|
||||||
pub mod storage;
|
|
||||||
|
|
||||||
|
mod chat;
|
||||||
mod errors;
|
mod errors;
|
||||||
mod proto;
|
mod proto;
|
||||||
|
mod storage;
|
||||||
mod types;
|
mod types;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
|
// Public API - this is what library users should use
|
||||||
|
pub use chat::{ChatManager, ChatManagerError, StorageConfig};
|
||||||
|
pub use inbox::Introduction;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
//! Chat-specific storage implementation.
|
//! Chat-specific storage implementation.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use storage::{RusqliteError, SqliteDb, StorageError, params};
|
use storage::{RusqliteError, SqliteDb, StorageError, params};
|
||||||
use x25519_dalek::StaticSecret;
|
|
||||||
|
|
||||||
use super::types::{ChatRecord, IdentityRecord};
|
use super::types::{ChatRecord, IdentityRecord};
|
||||||
use crate::identity::Identity;
|
use crate::identity::Identity;
|
||||||
@ -105,93 +102,6 @@ impl ChatStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if an identity exists.
|
|
||||||
pub fn has_identity(&self) -> Result<bool, StorageError> {
|
|
||||||
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<Option<StaticSecret>, 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<u8> = 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<HashMap<String, StaticSecret>, 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<u8> = 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.
|
/// Saves a chat record.
|
||||||
pub fn save_chat(&mut self, chat: &ChatRecord) -> Result<(), StorageError> {
|
pub fn save_chat(&mut self, chat: &ChatRecord) -> Result<(), StorageError> {
|
||||||
self.db.connection().execute(
|
self.db.connection().execute(
|
||||||
@ -208,64 +118,6 @@ impl ChatStorage {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads a chat record by ID.
|
|
||||||
pub fn load_chat(&self, chat_id: &str) -> Result<Option<ChatRecord>, 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<Vec<u8>> = 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<Vec<ChatRecord>, 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<Vec<u8>> = 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.
|
/// Lists all chat IDs.
|
||||||
pub fn list_chat_ids(&self) -> Result<Vec<String>, StorageError> {
|
pub fn list_chat_ids(&self) -> Result<Vec<String>, StorageError> {
|
||||||
let mut stmt = self.db.connection().prepare("SELECT chat_id FROM chats")?;
|
let mut stmt = self.db.connection().prepare("SELECT chat_id FROM chats")?;
|
||||||
@ -278,16 +130,6 @@ impl ChatStorage {
|
|||||||
|
|
||||||
Ok(ids)
|
Ok(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if a chat exists.
|
|
||||||
pub fn chat_exists(&self, chat_id: &str) -> Result<bool, StorageError> {
|
|
||||||
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)]
|
#[cfg(test)]
|
||||||
@ -299,7 +141,6 @@ mod tests {
|
|||||||
let mut storage = ChatStorage::in_memory().unwrap();
|
let mut storage = ChatStorage::in_memory().unwrap();
|
||||||
|
|
||||||
// Initially no identity
|
// Initially no identity
|
||||||
assert!(!storage.has_identity().unwrap());
|
|
||||||
assert!(storage.load_identity().unwrap().is_none());
|
assert!(storage.load_identity().unwrap().is_none());
|
||||||
|
|
||||||
// Save identity
|
// Save identity
|
||||||
@ -308,44 +149,16 @@ mod tests {
|
|||||||
storage.save_identity(&identity).unwrap();
|
storage.save_identity(&identity).unwrap();
|
||||||
|
|
||||||
// Load identity
|
// Load identity
|
||||||
assert!(storage.has_identity().unwrap());
|
|
||||||
let loaded = storage.load_identity().unwrap().unwrap();
|
let loaded = storage.load_identity().unwrap().unwrap();
|
||||||
assert_eq!(loaded.address(), address);
|
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]
|
#[test]
|
||||||
fn test_chat_roundtrip() {
|
fn test_chat_roundtrip() {
|
||||||
let mut storage = ChatStorage::in_memory().unwrap();
|
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(
|
let chat = ChatRecord::new_private(
|
||||||
"chat_123".to_string(),
|
"chat_123".to_string(),
|
||||||
remote_key,
|
remote_key,
|
||||||
@ -355,18 +168,8 @@ mod tests {
|
|||||||
// Save chat
|
// Save chat
|
||||||
storage.save_chat(&chat).unwrap();
|
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
|
// List chats
|
||||||
let ids = storage.list_chat_ids().unwrap();
|
let ids = storage.list_chat_ids().unwrap();
|
||||||
assert_eq!(ids, vec!["chat_123"]);
|
assert_eq!(ids, vec!["chat_123"]);
|
||||||
|
|
||||||
// Delete chat
|
|
||||||
storage.delete_chat("chat_123").unwrap();
|
|
||||||
assert!(!storage.chat_exists("chat_123").unwrap());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,12 +2,13 @@
|
|||||||
//!
|
//!
|
||||||
//! This module provides storage implementations for the chat manager state,
|
//! This module provides storage implementations for the chat manager state,
|
||||||
//! built on top of the shared `storage` crate.
|
//! 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 db;
|
||||||
mod session;
|
|
||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
pub use db::ChatStorage;
|
pub(crate) use db::ChatStorage;
|
||||||
pub use session::{ChatSession, SessionError};
|
pub(crate) use storage::StorageError;
|
||||||
pub use storage::{SqliteDb, StorageConfig, StorageError};
|
pub(crate) use types::ChatRecord;
|
||||||
pub use types::{ChatRecord, IdentityRecord, InboxKeyRecord};
|
|
||||||
|
|||||||
@ -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<Self, SessionError> {
|
|
||||||
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<Self, SessionError> {
|
|
||||||
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<Self, SessionError> {
|
|
||||||
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<Self, SessionError> {
|
|
||||||
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<Introduction, SessionError> {
|
|
||||||
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<AddressedEnvelope>), 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<Vec<AddressedEnvelope>, 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<ContentData, SessionError> {
|
|
||||||
let content = self.manager.handle_incoming(payload)?;
|
|
||||||
|
|
||||||
// TODO: Persist updated state
|
|
||||||
|
|
||||||
Ok(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all active chat IDs.
|
|
||||||
pub fn list_chats(&self) -> Vec<String> {
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -26,28 +26,6 @@ impl From<IdentityRecord> 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.
|
/// Record for storing chat metadata.
|
||||||
/// Note: The actual double ratchet state is stored separately by the DR storage.
|
/// Note: The actual double ratchet state is stored separately by the DR storage.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user