diff --git a/src/channels/matrix.rs b/src/channels/matrix.rs index 38a299716..3d5c8f96e 100644 --- a/src/channels/matrix.rs +++ b/src/channels/matrix.rs @@ -7,7 +7,7 @@ use matrix_sdk::{ events::reaction::ReactionEventContent, events::relation::{Annotation, InReplyTo, Thread}, events::room::message::{ - MessageType, OriginalSyncRoomMessageEvent, RoomMessageEventContent, + MessageType, OriginalSyncRoomMessageEvent, Relation, RoomMessageEventContent, }, events::room::MediaSource, OwnedEventId, OwnedRoomId, OwnedUserId, @@ -27,11 +27,13 @@ use tokio::sync::{mpsc, Mutex, OnceCell, RwLock}; #[derive(Clone)] pub struct MatrixChannel { homeserver: String, - access_token: String, + access_token: Option, room_id: String, allowed_users: Vec, session_owner_hint: Option, session_device_id_hint: Option, + password: Option, + recovery_key: Option, zeroclaw_dir: Option, resolved_room_id_cache: Arc>>, sdk_client: Arc>, @@ -115,7 +117,7 @@ impl MatrixChannel { pub fn new( homeserver: String, - access_token: String, + access_token: Option, room_id: String, allowed_users: Vec, ) -> Self { @@ -124,13 +126,13 @@ impl MatrixChannel { pub fn new_with_session_hint( homeserver: String, - access_token: String, + access_token: Option, room_id: String, allowed_users: Vec, owner_hint: Option, device_id_hint: Option, ) -> Self { - Self::new_with_session_hint_and_zeroclaw_dir( + Self::new_full( homeserver, access_token, room_id, @@ -138,20 +140,49 @@ impl MatrixChannel { owner_hint, device_id_hint, None, + None, + None, ) } pub fn new_with_session_hint_and_zeroclaw_dir( homeserver: String, - access_token: String, + access_token: Option, room_id: String, allowed_users: Vec, owner_hint: Option, device_id_hint: Option, zeroclaw_dir: Option, + ) -> Self { + Self::new_full( + homeserver, + access_token, + room_id, + allowed_users, + owner_hint, + device_id_hint, + None, + None, + zeroclaw_dir, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn new_full( + homeserver: String, + access_token: Option, + room_id: String, + allowed_users: Vec, + owner_hint: Option, + device_id_hint: Option, + password: Option, + recovery_key: Option, + zeroclaw_dir: Option, ) -> Self { let homeserver = homeserver.trim_end_matches('/').to_string(); - let access_token = access_token.trim().to_string(); + let access_token = access_token + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()); let room_id = room_id.trim().to_string(); let allowed_users = allowed_users .into_iter() @@ -166,6 +197,8 @@ impl MatrixChannel { allowed_users, session_owner_hint: Self::normalize_optional_field(owner_hint), session_device_id_hint: Self::normalize_optional_field(device_id_hint), + password: Self::normalize_optional_field(password), + recovery_key: Self::normalize_optional_field(recovery_key), zeroclaw_dir, resolved_room_id_cache: Arc::new(RwLock::new(None)), sdk_client: Arc::new(OnceCell::new()), @@ -197,7 +230,16 @@ impl MatrixChannel { } fn auth_header_value(&self) -> String { - format!("Bearer {}", self.access_token) + match self.access_token.as_deref() { + Some(token) => format!("Bearer {token}"), + None => String::new(), + } + } + + /// Returns true when the channel is configured for password-based login + /// (user_id + password present, no access_token). + fn uses_password_login(&self) -> bool { + self.access_token.is_none() && self.password.is_some() && self.session_owner_hint.is_some() } fn matrix_store_dir(&self) -> Option { @@ -285,108 +327,235 @@ impl MatrixChannel { Ok(self.get_my_identity().await?.user_id) } + /// Build (or reuse) the SDK client. + /// + /// Two login strategies are supported: + /// + /// 1. **Access-token restore** (legacy) — requires `access_token` plus + /// user_id/device_id from config hints or whoami. + /// 2. **Password login** — requires `user_id` + `password`. An optional + /// `device_id` hint reuses an existing device; otherwise the homeserver + /// allocates a new one. After login an optional `recovery_key` is used + /// to import E2EE room-key backups. async fn matrix_client(&self) -> anyhow::Result { let client = self .sdk_client .get_or_try_init(|| async { - let identity = self.get_my_identity().await; - let whoami = match identity { - Ok(whoami) => Some(whoami), - Err(error) => { - if self.session_owner_hint.is_some() && self.session_device_id_hint.is_some() - { - tracing::warn!( - "Matrix whoami failed; falling back to configured session hints for E2EE session restore: {error}" - ); - None - } else { - return Err(error); - } - } - }; - - let resolved_user_id = if let Some(whoami) = whoami.as_ref() { - if let Some(hinted) = self.session_owner_hint.as_ref() { - if hinted != &whoami.user_id { - tracing::warn!( - "Matrix configured user_id '{}' does not match whoami '{}'; using whoami.", - crate::security::redact(hinted), - crate::security::redact(&whoami.user_id) - ); - } - } - whoami.user_id.clone() + if self.uses_password_login() { + self.init_client_password_login().await } else { - self.session_owner_hint.clone().ok_or_else(|| { - anyhow::anyhow!( - "Matrix session restore requires user_id when whoami is unavailable" - ) - })? - }; - - let resolved_device_id = match (whoami.as_ref(), self.session_device_id_hint.as_ref()) { - (Some(whoami), Some(hinted)) => { - if let Some(whoami_device_id) = whoami.device_id.as_ref() { - if whoami_device_id != hinted { - tracing::warn!( - "Matrix configured device_id '{}' does not match whoami '{}'; using whoami.", - crate::security::redact(hinted), - crate::security::redact(whoami_device_id) - ); - } - whoami_device_id.clone() - } else { - hinted.clone() - } - } - (Some(whoami), None) => whoami.device_id.clone().ok_or_else(|| { - anyhow::anyhow!( - "Matrix whoami response did not include device_id. Set channels.matrix.device_id to enable E2EE session restore." - ) - })?, - (None, Some(hinted)) => hinted.clone(), - (None, None) => { - return Err(anyhow::anyhow!( - "Matrix E2EE session restore requires device_id when whoami is unavailable" - )); - } - }; - - let mut client_builder = MatrixSdkClient::builder().homeserver_url(&self.homeserver); - - if let Some(store_dir) = self.matrix_store_dir() { - tokio::fs::create_dir_all(&store_dir).await.map_err(|error| { - anyhow::anyhow!( - "Matrix failed to initialize persistent store directory at '{}': {error}", - store_dir.display() - ) - })?; - client_builder = client_builder.sqlite_store(&store_dir, None); + self.init_client_access_token().await } - - let client = client_builder.build().await?; - - let user_id: OwnedUserId = resolved_user_id.parse()?; - let session = MatrixSession { - meta: SessionMeta { - user_id, - device_id: resolved_device_id.into(), - }, - tokens: SessionTokens { - access_token: self.access_token.clone(), - refresh_token: None, - }, - }; - - client.restore_session(session).await?; - - Ok::(client) }) .await?; Ok(client.clone()) } + /// Password-based login: `user_id` + `password` are required. + /// Optionally reuses a `device_id` from config; the homeserver allocates + /// one when absent. After login the recovery key (if provided) is used + /// to pull E2EE room-key backups. + async fn init_client_password_login(&self) -> anyhow::Result { + let user_id_str = self.session_owner_hint.as_deref().ok_or_else(|| { + anyhow::anyhow!("Matrix password login requires user_id to be set in the config") + })?; + let password = self.password.as_deref().ok_or_else(|| { + anyhow::anyhow!("Matrix password login requires password to be set in the config") + })?; + + let mut client_builder = MatrixSdkClient::builder().homeserver_url(&self.homeserver); + if let Some(store_dir) = self.matrix_store_dir() { + tokio::fs::create_dir_all(&store_dir) + .await + .map_err(|error| { + anyhow::anyhow!( + "Matrix failed to initialize persistent store directory at '{}': {error}", + store_dir.display() + ) + })?; + client_builder = client_builder.sqlite_store(&store_dir, None); + } + let client = client_builder.build().await?; + + // Extract the localpart from a full user_id (@user:server) for the + // login call; matrix-sdk expects the localpart only. + let localpart = user_id_str + .strip_prefix('@') + .and_then(|rest| rest.split(':').next()) + .unwrap_or(user_id_str); + + let mut login_builder = client.matrix_auth().login_username(localpart, password); + + // Reuse an existing device_id when the user configured one. + let device_id_hint = self.session_device_id_hint.clone(); + if let Some(ref device_id) = device_id_hint { + login_builder = login_builder.device_id(device_id); + } + + let login_response = login_builder + .initial_device_display_name("ZeroClaw") + .await + .map_err(|error| anyhow::anyhow!("Matrix password login failed: {error}"))?; + + tracing::info!( + "Matrix password login succeeded for user '{}', device '{}'", + crate::security::redact(login_response.user_id.as_str()), + crate::security::redact(login_response.device_id.as_str()), + ); + + if let Some(ref hinted) = device_id_hint { + if hinted != login_response.device_id.as_str() { + tracing::warn!( + "Matrix password login returned a different device_id ('{}') than the configured hint ('{}'); a new device was created. Update channels.matrix.device_id to '{}' to reuse this device on next restart.", + crate::security::redact(login_response.device_id.as_str()), + crate::security::redact(hinted), + crate::security::redact(login_response.device_id.as_str()), + ); + } + } + + // Recovery key: import E2EE room-key backups so the bot can decrypt + // historical messages in encrypted rooms. + if let Some(ref recovery_key) = self.recovery_key { + tracing::info!("Matrix recovery key provided; importing E2EE room-key backup..."); + match client.encryption().recovery().recover(recovery_key).await { + Ok(()) => { + tracing::info!( + "Matrix E2EE room-key backup imported successfully via recovery key." + ); + } + Err(error) => { + tracing::warn!( + "Matrix recovery key import failed (E2EE history may be incomplete): {error}" + ); + } + } + } + + Ok(client) + } + + /// Access-token-based session restore (the original flow). + async fn init_client_access_token(&self) -> anyhow::Result { + let access_token = self.access_token.clone().ok_or_else(|| { + anyhow::anyhow!( + "Matrix channel requires either an access_token or a user_id + password for login" + ) + })?; + + let identity = self.get_my_identity().await; + let whoami = match identity { + Ok(whoami) => Some(whoami), + Err(error) => { + if self.session_owner_hint.is_some() && self.session_device_id_hint.is_some() { + tracing::warn!( + "Matrix whoami failed; falling back to configured session hints for E2EE session restore: {error}" + ); + None + } else { + return Err(error); + } + } + }; + + let resolved_user_id = if let Some(whoami) = whoami.as_ref() { + if let Some(hinted) = self.session_owner_hint.as_ref() { + if hinted != &whoami.user_id { + tracing::warn!( + "Matrix configured user_id '{}' does not match whoami '{}'; using whoami.", + crate::security::redact(hinted), + crate::security::redact(&whoami.user_id) + ); + } + } + whoami.user_id.clone() + } else { + self.session_owner_hint.clone().ok_or_else(|| { + anyhow::anyhow!( + "Matrix session restore requires user_id when whoami is unavailable" + ) + })? + }; + + let resolved_device_id = match (whoami.as_ref(), self.session_device_id_hint.as_ref()) { + (Some(whoami), Some(hinted)) => { + if let Some(whoami_device_id) = whoami.device_id.as_ref() { + if whoami_device_id != hinted { + tracing::warn!( + "Matrix configured device_id '{}' does not match whoami '{}'; using whoami.", + crate::security::redact(hinted), + crate::security::redact(whoami_device_id) + ); + } + whoami_device_id.clone() + } else { + hinted.clone() + } + } + (Some(whoami), None) => whoami.device_id.clone().ok_or_else(|| { + anyhow::anyhow!( + "Matrix whoami response did not include device_id. Set channels.matrix.device_id to enable E2EE session restore." + ) + })?, + (None, Some(hinted)) => hinted.clone(), + (None, None) => { + return Err(anyhow::anyhow!( + "Matrix E2EE session restore requires device_id when whoami is unavailable" + )); + } + }; + + let mut client_builder = MatrixSdkClient::builder().homeserver_url(&self.homeserver); + + if let Some(store_dir) = self.matrix_store_dir() { + tokio::fs::create_dir_all(&store_dir) + .await + .map_err(|error| { + anyhow::anyhow!( + "Matrix failed to initialize persistent store directory at '{}': {error}", + store_dir.display() + ) + })?; + client_builder = client_builder.sqlite_store(&store_dir, None); + } + + let client = client_builder.build().await?; + + let user_id: OwnedUserId = resolved_user_id.parse()?; + let session = MatrixSession { + meta: SessionMeta { + user_id, + device_id: resolved_device_id.into(), + }, + tokens: SessionTokens { + access_token, + refresh_token: None, + }, + }; + + client.restore_session(session).await?; + + // Recovery key: also supported in access-token mode. + if let Some(ref recovery_key) = self.recovery_key { + tracing::info!("Matrix recovery key provided; importing E2EE room-key backup..."); + match client.encryption().recovery().recover(recovery_key).await { + Ok(()) => { + tracing::info!( + "Matrix E2EE room-key backup imported successfully via recovery key." + ); + } + Err(error) => { + tracing::warn!( + "Matrix recovery key import failed (E2EE history may be incomplete): {error}" + ); + } + } + } + + Ok(client) + } + async fn resolve_room_id(&self) -> anyhow::Result { let configured = self.room_id.trim(); @@ -773,7 +942,10 @@ impl Channel for MatrixChannel { let client = reqwest::Client::new(); match client .get(&url) - .header("Authorization", format!("Bearer {}", access_token)) + .header( + "Authorization", + format!("Bearer {}", access_token.as_deref().unwrap_or_default()), + ) .send() .await { @@ -1094,7 +1266,7 @@ mod tests { fn make_channel() -> MatrixChannel { MatrixChannel::new( "https://matrix.org".to_string(), - "syt_test_token".to_string(), + Some("syt_test_token".to_string()), "!room:matrix.org".to_string(), vec!["@user:matrix.org".to_string()], ) @@ -1104,7 +1276,7 @@ mod tests { fn creates_with_correct_fields() { let ch = make_channel(); assert_eq!(ch.homeserver, "https://matrix.org"); - assert_eq!(ch.access_token, "syt_test_token"); + assert_eq!(ch.access_token.as_deref(), Some("syt_test_token")); assert_eq!(ch.room_id, "!room:matrix.org"); assert_eq!(ch.allowed_users.len(), 1); } @@ -1113,7 +1285,7 @@ mod tests { fn strips_trailing_slash() { let ch = MatrixChannel::new( "https://matrix.org/".to_string(), - "tok".to_string(), + Some("tok".to_string()), "!r:m".to_string(), vec![], ); @@ -1124,7 +1296,7 @@ mod tests { fn no_trailing_slash_unchanged() { let ch = MatrixChannel::new( "https://matrix.org".to_string(), - "tok".to_string(), + Some("tok".to_string()), "!r:m".to_string(), vec![], ); @@ -1135,7 +1307,7 @@ mod tests { fn multiple_trailing_slashes_strip_all() { let ch = MatrixChannel::new( "https://matrix.org//".to_string(), - "tok".to_string(), + Some("tok".to_string()), "!r:m".to_string(), vec![], ); @@ -1146,18 +1318,29 @@ mod tests { fn trims_access_token() { let ch = MatrixChannel::new( "https://matrix.org".to_string(), - " syt_test_token ".to_string(), + Some(" syt_test_token ".to_string()), "!r:m".to_string(), vec![], ); - assert_eq!(ch.access_token, "syt_test_token"); + assert_eq!(ch.access_token.as_deref(), Some("syt_test_token")); + } + + #[test] + fn empty_access_token_becomes_none() { + let ch = MatrixChannel::new( + "https://matrix.org".to_string(), + Some(" ".to_string()), + "!r:m".to_string(), + vec![], + ); + assert!(ch.access_token.is_none()); } #[test] fn session_hints_are_normalized() { let ch = MatrixChannel::new_with_session_hint( "https://matrix.org".to_string(), - "tok".to_string(), + Some("tok".to_string()), "!r:m".to_string(), vec![], Some(" @bot:matrix.org ".to_string()), @@ -1172,7 +1355,7 @@ mod tests { fn empty_session_hints_are_ignored() { let ch = MatrixChannel::new_with_session_hint( "https://matrix.org".to_string(), - "tok".to_string(), + Some("tok".to_string()), "!r:m".to_string(), vec![], Some(" ".to_string()), @@ -1187,7 +1370,7 @@ mod tests { fn matrix_store_dir_is_derived_from_zeroclaw_dir() { let ch = MatrixChannel::new_with_session_hint_and_zeroclaw_dir( "https://matrix.org".to_string(), - "tok".to_string(), + Some("tok".to_string()), "!r:m".to_string(), vec![], None, @@ -1205,7 +1388,7 @@ mod tests { fn matrix_store_dir_absent_without_zeroclaw_dir() { let ch = MatrixChannel::new_with_session_hint( "https://matrix.org".to_string(), - "tok".to_string(), + Some("tok".to_string()), "!r:m".to_string(), vec![], None, @@ -1215,6 +1398,73 @@ mod tests { assert!(ch.matrix_store_dir().is_none()); } + #[test] + fn password_login_detection() { + // Password login: no access_token, has password + user_id + let ch = MatrixChannel::new_full( + "https://matrix.org".to_string(), + None, + "!r:m".to_string(), + vec![], + Some("@bot:matrix.org".to_string()), + None, + Some("hunter2".to_string()), + None, + None, + ); + assert!(ch.uses_password_login()); + + // Access token login: has access_token + let ch2 = make_channel(); + assert!(!ch2.uses_password_login()); + + // Incomplete password config: no user_id + let ch3 = MatrixChannel::new_full( + "https://matrix.org".to_string(), + None, + "!r:m".to_string(), + vec![], + None, + None, + Some("hunter2".to_string()), + None, + None, + ); + assert!(!ch3.uses_password_login()); + } + + #[test] + fn recovery_key_is_normalized() { + let ch = MatrixChannel::new_full( + "https://matrix.org".to_string(), + Some("tok".to_string()), + "!r:m".to_string(), + vec![], + None, + None, + None, + Some(" EsT0 Abcd Efgh ".to_string()), + None, + ); + assert_eq!(ch.recovery_key.as_deref(), Some("EsT0 Abcd Efgh")); + } + + #[test] + fn empty_recovery_key_becomes_none() { + let ch = MatrixChannel::new_full( + "https://matrix.org".to_string(), + Some("tok".to_string()), + "!r:m".to_string(), + vec![], + None, + None, + None, + Some(" ".to_string()), + None, + ); + assert!(ch.recovery_key.is_none()); + } + #[test] fn encode_path_segment_encodes_room_refs() { assert_eq!( @@ -1298,7 +1548,7 @@ mod tests { fn trims_room_id_and_allowed_users() { let ch = MatrixChannel::new( "https://matrix.org".to_string(), - "tok".to_string(), + Some("tok".to_string()), " !room:matrix.org ".to_string(), vec![ " @user:matrix.org ".to_string(), @@ -1317,7 +1567,7 @@ mod tests { fn wildcard_allows_anyone() { let ch = MatrixChannel::new( "https://m.org".to_string(), - "tok".to_string(), + Some("tok".to_string()), "!r:m".to_string(), vec!["*".to_string()], ); @@ -1342,7 +1592,7 @@ mod tests { fn user_case_insensitive() { let ch = MatrixChannel::new( "https://m.org".to_string(), - "tok".to_string(), + Some("tok".to_string()), "!r:m".to_string(), vec!["@User:Matrix.org".to_string()], ); @@ -1354,7 +1604,7 @@ mod tests { fn empty_allowlist_denies_all() { let ch = MatrixChannel::new( "https://m.org".to_string(), - "tok".to_string(), + Some("tok".to_string()), "!r:m".to_string(), vec![], ); @@ -1477,7 +1727,7 @@ mod tests { async fn invalid_room_reference_fails_fast() { let ch = MatrixChannel::new( "https://matrix.org".to_string(), - "tok".to_string(), + Some("tok".to_string()), "room_without_prefix".to_string(), vec![], ); @@ -1492,7 +1742,7 @@ mod tests { async fn target_room_id_keeps_canonical_room_id_without_lookup() { let ch = MatrixChannel::new( "https://matrix.org".to_string(), - "tok".to_string(), + Some("tok".to_string()), "!canonical:matrix.org".to_string(), vec![], ); @@ -1505,7 +1755,7 @@ mod tests { async fn target_room_id_uses_cached_alias_resolution() { let ch = MatrixChannel::new( "https://matrix.org".to_string(), - "tok".to_string(), + Some("tok".to_string()), "#ops:matrix.org".to_string(), vec![], ); diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 610fe7151..561ff3c41 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -2932,13 +2932,15 @@ fn collect_configured_channels( if let Some(ref mx) = config.channels_config.matrix { channels.push(ConfiguredChannel { display_name: "Matrix", - channel: Arc::new(MatrixChannel::new_with_session_hint_and_zeroclaw_dir( + channel: Arc::new(MatrixChannel::new_full( mx.homeserver.clone(), mx.access_token.clone(), mx.room_id.clone(), mx.allowed_users.clone(), mx.user_id.clone(), mx.device_id.clone(), + mx.password.clone(), + mx.recovery_key.clone(), config.config_path.parent().map(|path| path.to_path_buf()), )), }); diff --git a/src/config/schema.rs b/src/config/schema.rs index c0f7f6d08..9d1f3f927 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -3129,7 +3129,9 @@ pub struct MatrixConfig { /// Matrix homeserver URL (e.g. `"https://matrix.org"`). pub homeserver: String, /// Matrix access token for the bot account. - pub access_token: String, + /// Required when using access-token login; auto-populated when using password login. + #[serde(default)] + pub access_token: Option, /// Optional Matrix user ID (e.g. `"@bot:matrix.org"`). #[serde(default)] pub user_id: Option, @@ -3140,6 +3142,16 @@ pub struct MatrixConfig { pub room_id: String, /// Allowed Matrix user IDs. Empty = deny all. pub allowed_users: Vec, + /// Optional Matrix account password for password-based login. + /// When set (along with `user_id`), the bot logs in with password instead of a + /// pre-existing access token. The resulting access token is managed automatically. + #[serde(default)] + pub password: Option, + /// Optional E2EE recovery key (also called "security key") for decrypting + /// room history in encrypted rooms. When provided, the bot imports the key + /// backup after login so it can read messages from before its session started. + #[serde(default)] + pub recovery_key: Option, } impl ChannelConfig for MatrixConfig { @@ -4393,11 +4405,21 @@ impl Config { )?; } if let Some(ref mut mx) = config.channels_config.matrix { - decrypt_secret( + decrypt_optional_secret( &store, &mut mx.access_token, "config.channels_config.matrix.access_token", )?; + decrypt_optional_secret( + &store, + &mut mx.password, + "config.channels_config.matrix.password", + )?; + decrypt_optional_secret( + &store, + &mut mx.recovery_key, + "config.channels_config.matrix.recovery_key", + )?; } if let Some(ref mut wa) = config.channels_config.whatsapp { decrypt_optional_secret( @@ -5230,11 +5252,21 @@ impl Config { )?; } if let Some(ref mut mx) = config_to_save.channels_config.matrix { - encrypt_secret( + encrypt_optional_secret( &store, &mut mx.access_token, "config.channels_config.matrix.access_token", )?; + encrypt_optional_secret( + &store, + &mut mx.password, + "config.channels_config.matrix.password", + )?; + encrypt_optional_secret( + &store, + &mut mx.recovery_key, + "config.channels_config.matrix.recovery_key", + )?; } if let Some(ref mut wa) = config_to_save.channels_config.whatsapp { encrypt_optional_secret( @@ -6235,16 +6267,18 @@ tool_dispatcher = "xml" async fn matrix_config_serde() { let mc = MatrixConfig { homeserver: "https://matrix.org".into(), - access_token: "syt_token_abc".into(), + access_token: Some("syt_token_abc".into()), user_id: Some("@bot:matrix.org".into()), device_id: Some("DEVICE123".into()), room_id: "!room123:matrix.org".into(), allowed_users: vec!["@user:matrix.org".into()], + password: None, + recovery_key: None, }; let json = serde_json::to_string(&mc).unwrap(); let parsed: MatrixConfig = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.homeserver, "https://matrix.org"); - assert_eq!(parsed.access_token, "syt_token_abc"); + assert_eq!(parsed.access_token.as_deref(), Some("syt_token_abc")); assert_eq!(parsed.user_id.as_deref(), Some("@bot:matrix.org")); assert_eq!(parsed.device_id.as_deref(), Some("DEVICE123")); assert_eq!(parsed.room_id, "!room123:matrix.org"); @@ -6255,11 +6289,13 @@ tool_dispatcher = "xml" async fn matrix_config_toml_roundtrip() { let mc = MatrixConfig { homeserver: "https://synapse.local:8448".into(), - access_token: "tok".into(), + access_token: Some("tok".into()), user_id: None, device_id: None, room_id: "!abc:synapse.local".into(), allowed_users: vec!["@admin:synapse.local".into(), "*".into()], + password: None, + recovery_key: None, }; let toml_str = toml::to_string(&mc).unwrap(); let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap(); @@ -6280,6 +6316,27 @@ allowed_users = ["@ops:matrix.org"] assert_eq!(parsed.homeserver, "https://matrix.org"); assert!(parsed.user_id.is_none()); assert!(parsed.device_id.is_none()); + assert!(parsed.password.is_none()); + assert!(parsed.recovery_key.is_none()); + } + + #[test] + async fn matrix_config_password_login_fields() { + let mc = MatrixConfig { + homeserver: "https://matrix.org".into(), + access_token: None, + user_id: Some("@bot:matrix.org".into()), + device_id: None, + room_id: "!room:matrix.org".into(), + allowed_users: vec!["*".into()], + password: Some("hunter2".into()), + recovery_key: Some("EsT0 Abcd Efgh Ijkl".into()), + }; + let json = serde_json::to_string(&mc).unwrap(); + let parsed: MatrixConfig = serde_json::from_str(&json).unwrap(); + assert!(parsed.access_token.is_none()); + assert_eq!(parsed.password.as_deref(), Some("hunter2")); + assert_eq!(parsed.recovery_key.as_deref(), Some("EsT0 Abcd Efgh Ijkl")); } #[test] @@ -6344,11 +6401,13 @@ allowed_users = ["@ops:matrix.org"] }), matrix: Some(MatrixConfig { homeserver: "https://m.org".into(), - access_token: "tok".into(), + access_token: Some("tok".into()), user_id: None, device_id: None, room_id: "!r:m".into(), allowed_users: vec!["@u:m".into()], + password: None, + recovery_key: None, }), signal: None, whatsapp: None, diff --git a/src/gateway/api.rs b/src/gateway/api.rs index 2734dcaa3..a5a857a64 100644 --- a/src/gateway/api.rs +++ b/src/gateway/api.rs @@ -772,7 +772,9 @@ fn mask_sensitive_fields(config: &crate::config::Config) -> crate::config::Confi mask_optional_secret(&mut webhook.secret); } if let Some(matrix) = masked.channels_config.matrix.as_mut() { - mask_required_secret(&mut matrix.access_token); + mask_optional_secret(&mut matrix.access_token); + mask_optional_secret(&mut matrix.password); + mask_optional_secret(&mut matrix.recovery_key); } if let Some(whatsapp) = masked.channels_config.whatsapp.as_mut() { mask_optional_secret(&mut whatsapp.access_token); @@ -911,7 +913,9 @@ fn restore_masked_sensitive_fields( incoming.channels_config.matrix.as_mut(), current.channels_config.matrix.as_ref(), ) { - restore_required_secret(&mut incoming_ch.access_token, ¤t_ch.access_token); + restore_optional_secret(&mut incoming_ch.access_token, ¤t_ch.access_token); + restore_optional_secret(&mut incoming_ch.password, ¤t_ch.password); + restore_optional_secret(&mut incoming_ch.recovery_key, ¤t_ch.recovery_key); } if let (Some(incoming_ch), Some(current_ch)) = ( incoming.channels_config.whatsapp.as_mut(), diff --git a/src/integrations/registry.rs b/src/integrations/registry.rs index 7a9d1fa17..b3a6c9444 100644 --- a/src/integrations/registry.rs +++ b/src/integrations/registry.rs @@ -860,11 +860,13 @@ mod tests { let mut config = Config::default(); config.channels_config.matrix = Some(MatrixConfig { homeserver: "https://m.org".into(), - access_token: "tok".into(), + access_token: Some("tok".into()), user_id: None, device_id: None, room_id: "!r:m".into(), allowed_users: vec![], + password: None, + recovery_key: None, }); let entries = all_integrations(); let mx = entries.iter().find(|e| e.name == "Matrix").unwrap(); diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 9200ba57d..ef135a17a 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -3909,8 +3909,6 @@ fn setup_channels() -> Result { style("Matrix Setup").white().bold(), style("— self-hosted, federated chat").dim() ); - print_bullet("You need a Matrix account and an access token."); - print_bullet("Get a token via Element → Settings → Help & About → Access Token."); println!(); let homeserver: String = Input::new() @@ -3922,72 +3920,197 @@ fn setup_channels() -> Result { continue; } - let access_token: String = - Input::new().with_prompt(" Access token").interact_text()?; + // Login method selection + let login_methods = &["Password login (recommended)", "Access token (manual)"]; + let login_choice = Select::new() + .with_prompt(" Login method") + .items(login_methods) + .default(0) + .interact()?; - if access_token.trim().is_empty() { - println!(" {} Skipped — token required", style("→").dim()); - continue; - } + let (access_token, password, detected_user_id, detected_device_id, recovery_key) = + if login_choice == 0 { + // ── Password login ── + print_bullet("Enter your Matrix user ID and password."); + print_bullet("A device session will be created automatically."); + println!(); - // Test connection (run entirely in separate thread — Response must be used/dropped there) - let hs = homeserver.trim_end_matches('/'); - print!(" {} Testing connection... ", style("⏳").dim()); - let hs_owned = hs.to_string(); - let access_token_clone = access_token.clone(); - let thread_result = std::thread::spawn(move || { - let client = reqwest::blocking::Client::new(); - let resp = client - .get(format!("{hs_owned}/_matrix/client/v3/account/whoami")) - .header("Authorization", format!("Bearer {access_token_clone}")) - .send()?; - let ok = resp.status().is_success(); + let user_id: String = Input::new() + .with_prompt(" User ID (e.g. @bot:matrix.org)") + .interact_text()?; - if !ok { - return Ok::<_, reqwest::Error>((false, None, None)); - } - - let payload: Value = match resp.json() { - Ok(payload) => payload, - Err(_) => Value::Null, - }; - let user_id = payload - .get("user_id") - .and_then(|value| value.as_str()) - .map(|value| value.to_string()); - let device_id = payload - .get("device_id") - .and_then(|value| value.as_str()) - .map(|value| value.to_string()); - - Ok::<_, reqwest::Error>((true, user_id, device_id)) - }) - .join(); - - let (detected_user_id, detected_device_id) = match thread_result { - Ok(Ok((true, user_id, device_id))) => { - println!( - "\r {} Connection verified ", - style("✅").green().bold() - ); - - if device_id.is_none() { - println!( - " {} Homeserver did not return device_id from whoami. If E2EE decryption fails, set channels.matrix.device_id manually in config.toml.", - style("⚠️").yellow().bold() - ); + if user_id.trim().is_empty() { + println!(" {} Skipped — user ID required", style("→").dim()); + continue; } - (user_id, device_id) - } - _ => { - println!( - "\r {} Connection failed — check homeserver URL and token", - style("❌").red().bold() + let pw: String = Input::new().with_prompt(" Password").interact_text()?; + + if pw.trim().is_empty() { + println!(" {} Skipped — password required", style("→").dim()); + continue; + } + + // Test password login + let hs = homeserver.trim_end_matches('/'); + print!(" {} Testing password login... ", style("⏳").dim()); + let hs_owned = hs.to_string(); + let user_id_clone = user_id.clone(); + let pw_clone = pw.clone(); + let thread_result = std::thread::spawn(move || { + let client = reqwest::blocking::Client::new(); + let localpart = user_id_clone + .strip_prefix('@') + .and_then(|rest| rest.split(':').next()) + .unwrap_or(&user_id_clone); + let body = serde_json::json!({ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": localpart + }, + "password": pw_clone, + "initial_device_display_name": "ZeroClaw (setup check)" + }); + let resp = client + .post(format!("{hs_owned}/_matrix/client/v3/login")) + .json(&body) + .send()?; + let ok = resp.status().is_success(); + if !ok { + return Ok::<_, reqwest::Error>((false, None)); + } + let payload: Value = resp.json().unwrap_or(Value::Null); + let device_id = payload + .get("device_id") + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + Ok::<_, reqwest::Error>((true, device_id)) + }) + .join(); + + let detected_device_id = match thread_result { + Ok(Ok((true, device_id))) => { + println!( + "\r {} Password login verified ", + style("✅").green().bold() + ); + device_id + } + _ => { + println!( + "\r {} Password login failed — check credentials and homeserver URL", + style("❌").red().bold() + ); + continue; + } + }; + + // Optional recovery key for E2EE + println!(); + print_bullet("Optional: provide a recovery key (security key) to decrypt E2EE room history."); + print_bullet("Leave empty to skip (you can add it later in config.toml)."); + let rk: String = Input::new() + .with_prompt(" Recovery key (or empty to skip)") + .default(String::new()) + .interact_text()?; + + let recovery_key = if rk.trim().is_empty() { + None + } else { + Some(rk.trim().to_string()) + }; + + ( + None, + Some(pw), + Some(user_id), + detected_device_id, + recovery_key, + ) + } else { + // ── Access token login (legacy) ── + print_bullet("You need a Matrix account and an access token."); + print_bullet( + "Get a token via Element → Settings → Help & About → Access Token.", ); - continue; - } - }; + println!(); + + let access_token: String = + Input::new().with_prompt(" Access token").interact_text()?; + + if access_token.trim().is_empty() { + println!(" {} Skipped — token required", style("→").dim()); + continue; + } + + // Test connection + let hs = homeserver.trim_end_matches('/'); + print!(" {} Testing connection... ", style("⏳").dim()); + let hs_owned = hs.to_string(); + let access_token_clone = access_token.clone(); + let thread_result = std::thread::spawn(move || { + let client = reqwest::blocking::Client::new(); + let resp = client + .get(format!("{hs_owned}/_matrix/client/v3/account/whoami")) + .header("Authorization", format!("Bearer {access_token_clone}")) + .send()?; + let ok = resp.status().is_success(); + + if !ok { + return Ok::<_, reqwest::Error>((false, None, None)); + } + + let payload: Value = match resp.json() { + Ok(payload) => payload, + Err(_) => Value::Null, + }; + let user_id = payload + .get("user_id") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()); + let device_id = payload + .get("device_id") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()); + + Ok::<_, reqwest::Error>((true, user_id, device_id)) + }) + .join(); + + let (detected_user_id, detected_device_id) = match thread_result { + Ok(Ok((true, user_id, device_id))) => { + println!( + "\r {} Connection verified ", + style("✅").green().bold() + ); + + if device_id.is_none() { + println!( + " {} Homeserver did not return device_id from whoami. If E2EE decryption fails, set channels.matrix.device_id manually in config.toml.", + style("⚠️").yellow().bold() + ); + } + + (user_id, device_id) + } + _ => { + println!( + "\r {} Connection failed — check homeserver URL and token", + style("❌").red().bold() + ); + continue; + } + }; + + ( + Some(access_token), + None, + detected_user_id, + detected_device_id, + None, + ) + }; let room_id: String = Input::new() .with_prompt(" Room ID (e.g. !abc123:matrix.org)") @@ -4011,6 +4134,8 @@ fn setup_channels() -> Result { device_id: detected_device_id, room_id, allowed_users, + password, + recovery_key, }); } ChannelMenuChoice::Signal => {