308 lines
11 KiB
Rust
308 lines
11 KiB
Rust
use std::{
|
|
io::{self, Write},
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
use matrix_sdk::{
|
|
config::SyncSettings,
|
|
room::Room,
|
|
ruma::{
|
|
api::client::filter::FilterDefinition,
|
|
events::room::message::{MessageType, OriginalSyncRoomMessageEvent},
|
|
},
|
|
Client, Error, LoopCtrl, Session,
|
|
};
|
|
use rand::{distributions::Alphanumeric, thread_rng, Rng};
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::fs;
|
|
|
|
/// The data needed to re-build a client.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct ClientSession {
|
|
/// The URL of the homeserver of the user.
|
|
homeserver: String,
|
|
|
|
/// The path of the database.
|
|
db_path: PathBuf,
|
|
|
|
/// The passphrase of the database.
|
|
passphrase: String,
|
|
}
|
|
|
|
/// The full session to persist.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct FullSession {
|
|
/// The data to re-build the client.
|
|
client_session: ClientSession,
|
|
|
|
/// The Matrix user session.
|
|
user_session: Session,
|
|
|
|
/// The latest sync token.
|
|
///
|
|
/// It is only needed to persist it when using `Client::sync_once()` and we
|
|
/// want to make our syncs faster by not receiving all the initial sync
|
|
/// again.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
sync_token: Option<String>,
|
|
}
|
|
|
|
/// A simple example to show how to persist a client's data to be able to
|
|
/// restore it.
|
|
///
|
|
/// Restoring a session with encryption without having a persisted store
|
|
/// will break the encryption setup and the client will not be able to send or
|
|
/// receive encrypted messages, hence the need to persist the session.
|
|
///
|
|
/// To use this, just run `cargo run -p example-persist-session`, and everything
|
|
/// is interactive after that. You might want to set the `RUST_LOG` environment
|
|
/// variable to `warn` to reduce the noise in the logs. The program exits
|
|
/// whenever an unexpected error occurs.
|
|
///
|
|
/// To reset the login, simply delete the folder containing the session
|
|
/// file, the location is shown in the logs. Note that the database must be
|
|
/// deleted too as it can't be reused.
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
tracing_subscriber::fmt::init();
|
|
|
|
// The folder containing this example's data.
|
|
let data_dir = dirs::data_dir().expect("no data_dir directory found").join("persist_session");
|
|
// The file where the session is persisted.
|
|
let session_file = data_dir.join("session");
|
|
|
|
let (client, sync_token) = if session_file.exists() {
|
|
restore_session(&session_file).await?
|
|
} else {
|
|
(login(&data_dir, &session_file).await?, None)
|
|
};
|
|
|
|
sync(client, sync_token, &session_file).await.map_err(Into::into)
|
|
}
|
|
|
|
/// Restore a previous session.
|
|
async fn restore_session(session_file: &Path) -> anyhow::Result<(Client, Option<String>)> {
|
|
println!("Previous session found in '{}'", session_file.to_string_lossy());
|
|
|
|
// The session was serialized as JSON in a file.
|
|
let serialized_session = fs::read_to_string(session_file).await?;
|
|
let FullSession { client_session, user_session, sync_token } =
|
|
serde_json::from_str(&serialized_session)?;
|
|
|
|
// Build the client with the previous settings from the session.
|
|
let client = Client::builder()
|
|
.homeserver_url(client_session.homeserver)
|
|
.sqlite_store(client_session.db_path, Some(&client_session.passphrase))
|
|
.build()
|
|
.await?;
|
|
|
|
println!("Restoring session for {}…", user_session.user_id);
|
|
|
|
// Restore the Matrix user session.
|
|
client.restore_session(user_session).await?;
|
|
|
|
Ok((client, sync_token))
|
|
}
|
|
|
|
/// Login with a new device.
|
|
async fn login(data_dir: &Path, session_file: &Path) -> anyhow::Result<Client> {
|
|
println!("No previous session found, logging in…");
|
|
|
|
let (client, client_session) = build_client(data_dir).await?;
|
|
|
|
loop {
|
|
print!("\nUsername: ");
|
|
io::stdout().flush().expect("Unable to write to stdout");
|
|
let mut username = String::new();
|
|
io::stdin().read_line(&mut username).expect("Unable to read user input");
|
|
username = username.trim().to_owned();
|
|
|
|
print!("Password: ");
|
|
io::stdout().flush().expect("Unable to write to stdout");
|
|
let mut password = String::new();
|
|
io::stdin().read_line(&mut password).expect("Unable to read user input");
|
|
password = password.trim().to_owned();
|
|
|
|
match client
|
|
.login_username(&username, &password)
|
|
.initial_device_display_name("persist-session client")
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
println!("Logged in as {username}");
|
|
break;
|
|
}
|
|
Err(error) => {
|
|
println!("Error logging in: {error}");
|
|
println!("Please try again\n");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Persist the session to reuse it later.
|
|
// This is not very secure, for simplicity. If the system provides a way of
|
|
// storing secrets securely, it should be used instead.
|
|
// Note that we could also build the user session from the login response.
|
|
let user_session = client.session().expect("A logged-in client should have a session");
|
|
let serialized_session =
|
|
serde_json::to_string(&FullSession { client_session, user_session, sync_token: None })?;
|
|
fs::write(session_file, serialized_session).await?;
|
|
|
|
println!("Session persisted in {}", session_file.to_string_lossy());
|
|
|
|
// After logging in, you might want to verify this session with another one (see
|
|
// the `emoji_verification` example), or bootstrap cross-signing if this is your
|
|
// first session with encryption, or if you need to reset cross-signing because
|
|
// you don't have access to your old sessions (see the
|
|
// `cross_signing_bootstrap` example).
|
|
|
|
Ok(client)
|
|
}
|
|
|
|
/// Build a new client.
|
|
async fn build_client(data_dir: &Path) -> anyhow::Result<(Client, ClientSession)> {
|
|
let mut rng = thread_rng();
|
|
|
|
// Generating a subfolder for the database is not mandatory, but it is useful if
|
|
// you allow several clients to run at the same time. Each one must have a
|
|
// separate database, which is a different folder with the SQLite store.
|
|
let db_subfolder: String =
|
|
(&mut rng).sample_iter(Alphanumeric).take(7).map(char::from).collect();
|
|
let db_path = data_dir.join(db_subfolder);
|
|
|
|
// Generate a random passphrase.
|
|
let passphrase: String =
|
|
(&mut rng).sample_iter(Alphanumeric).take(32).map(char::from).collect();
|
|
|
|
// We create a loop here so the user can retry if an error happens.
|
|
loop {
|
|
let mut homeserver = String::new();
|
|
|
|
print!("Homeserver URL: ");
|
|
io::stdout().flush().expect("Unable to write to stdout");
|
|
io::stdin().read_line(&mut homeserver).expect("Unable to read user input");
|
|
|
|
println!("\nChecking homeserver…");
|
|
|
|
match Client::builder()
|
|
.homeserver_url(&homeserver)
|
|
// We use the SQLite store, which is enabled by default. This is the crucial part to
|
|
// persist the encryption setup.
|
|
// Note that other store backends are available and you can even implement your own.
|
|
.sqlite_store(&db_path, Some(&passphrase))
|
|
.build()
|
|
.await
|
|
{
|
|
Ok(client) => return Ok((client, ClientSession { homeserver, db_path, passphrase })),
|
|
Err(error) => match &error {
|
|
matrix_sdk::ClientBuildError::AutoDiscovery(_)
|
|
| matrix_sdk::ClientBuildError::Url(_)
|
|
| matrix_sdk::ClientBuildError::Http(_) => {
|
|
println!("Error checking the homeserver: {error}");
|
|
println!("Please try again\n");
|
|
}
|
|
_ => {
|
|
// Forward other errors, it's unlikely we can retry with a different outcome.
|
|
return Err(error.into());
|
|
}
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Setup the client to listen to new messages.
|
|
async fn sync(
|
|
client: Client,
|
|
initial_sync_token: Option<String>,
|
|
session_file: &Path,
|
|
) -> anyhow::Result<()> {
|
|
println!("Launching a first sync to ignore past messages…");
|
|
|
|
// Enable room members lazy-loading, it will speed up the initial sync a lot
|
|
// with accounts in lots of rooms.
|
|
// See <https://spec.matrix.org/v1.6/client-server-api/#lazy-loading-room-members>.
|
|
let filter = FilterDefinition::with_lazy_loading();
|
|
|
|
let mut sync_settings = SyncSettings::default().filter(filter.into());
|
|
|
|
// We restore the sync where we left.
|
|
// This is not necessary when not using `sync_once`. The other sync methods get
|
|
// the sync token from the store.
|
|
if let Some(sync_token) = initial_sync_token {
|
|
sync_settings = sync_settings.token(sync_token);
|
|
}
|
|
|
|
// Let's ignore messages before the program was launched.
|
|
// This is a loop in case the initial sync is longer than our timeout. The
|
|
// server should cache the response and it will ultimately take less time to
|
|
// receive.
|
|
loop {
|
|
match client.sync_once(sync_settings.clone()).await {
|
|
Ok(response) => {
|
|
// This is the last time we need to provide this token, the sync method after
|
|
// will handle it on its own.
|
|
sync_settings = sync_settings.token(response.next_batch.clone());
|
|
persist_sync_token(session_file, response.next_batch).await?;
|
|
break;
|
|
}
|
|
Err(error) => {
|
|
println!("An error occurred during initial sync: {error}");
|
|
println!("Trying again…");
|
|
}
|
|
}
|
|
}
|
|
|
|
println!("The client is ready! Listening to new messages…");
|
|
|
|
// Now that we've synced, let's attach a handler for incoming room messages.
|
|
client.add_event_handler(on_room_message);
|
|
|
|
// This loops until we kill the program or an error happens.
|
|
client
|
|
.sync_with_result_callback(sync_settings, |sync_result| async move {
|
|
let response = sync_result?;
|
|
|
|
// We persist the token each time to be able to restore our session
|
|
persist_sync_token(session_file, response.next_batch)
|
|
.await
|
|
.map_err(|err| Error::UnknownError(err.into()))?;
|
|
|
|
Ok(LoopCtrl::Continue)
|
|
})
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Persist the sync token for a future session.
|
|
/// Note that this is needed only when using `sync_once`. Other sync methods get
|
|
/// the sync token from the store.
|
|
async fn persist_sync_token(session_file: &Path, sync_token: String) -> anyhow::Result<()> {
|
|
let serialized_session = fs::read_to_string(session_file).await?;
|
|
let mut full_session: FullSession = serde_json::from_str(&serialized_session)?;
|
|
|
|
full_session.sync_token = Some(sync_token);
|
|
let serialized_session = serde_json::to_string(&full_session)?;
|
|
fs::write(session_file, serialized_session).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Handle room messages.
|
|
async fn on_room_message(event: OriginalSyncRoomMessageEvent, room: Room) {
|
|
// We only want to log text messages in joined rooms.
|
|
let Room::Joined(room) = room else { return };
|
|
let MessageType::Text(text_content) = &event.content.msgtype else { return };
|
|
|
|
let room_name = match room.display_name().await {
|
|
Ok(room_name) => room_name.to_string(),
|
|
Err(error) => {
|
|
println!("Error getting room display name: {error}");
|
|
// Let's fallback to the room ID.
|
|
room.room_id().to_string()
|
|
}
|
|
};
|
|
|
|
println!("[{room_name}] {}: {}", event.sender, text_content.body)
|
|
}
|