feat(channel): add password-based Matrix login and recovery key for E2EE (#2916)
Add password-based login as an alternative to manual access token copying for the Matrix channel. Users can now provide user_id + password instead of obtaining an access token from Element. Also add recovery_key support to import E2EE room-key backups for decrypting encrypted room history. Changes: - MatrixConfig: access_token is now Optional, add password and recovery_key fields - MatrixChannel: new_full() constructor, init_client_password_login() using matrix_sdk login_username API, recovery key import via encryption().recovery().recover() - Onboarding wizard: login method selector (password vs access token) with recovery key prompt for password flow - Gateway API: mask/restore new secret fields - Secret store: encrypt/decrypt password and recovery_key Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7170810e98
commit
6bd363f139
@ -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<String>,
|
||||
room_id: String,
|
||||
allowed_users: Vec<String>,
|
||||
session_owner_hint: Option<String>,
|
||||
session_device_id_hint: Option<String>,
|
||||
password: Option<String>,
|
||||
recovery_key: Option<String>,
|
||||
zeroclaw_dir: Option<PathBuf>,
|
||||
resolved_room_id_cache: Arc<RwLock<Option<String>>>,
|
||||
sdk_client: Arc<OnceCell<MatrixSdkClient>>,
|
||||
@ -115,7 +117,7 @@ impl MatrixChannel {
|
||||
|
||||
pub fn new(
|
||||
homeserver: String,
|
||||
access_token: String,
|
||||
access_token: Option<String>,
|
||||
room_id: String,
|
||||
allowed_users: Vec<String>,
|
||||
) -> Self {
|
||||
@ -124,13 +126,13 @@ impl MatrixChannel {
|
||||
|
||||
pub fn new_with_session_hint(
|
||||
homeserver: String,
|
||||
access_token: String,
|
||||
access_token: Option<String>,
|
||||
room_id: String,
|
||||
allowed_users: Vec<String>,
|
||||
owner_hint: Option<String>,
|
||||
device_id_hint: Option<String>,
|
||||
) -> 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<String>,
|
||||
room_id: String,
|
||||
allowed_users: Vec<String>,
|
||||
owner_hint: Option<String>,
|
||||
device_id_hint: Option<String>,
|
||||
zeroclaw_dir: Option<PathBuf>,
|
||||
) -> 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<String>,
|
||||
room_id: String,
|
||||
allowed_users: Vec<String>,
|
||||
owner_hint: Option<String>,
|
||||
device_id_hint: Option<String>,
|
||||
password: Option<String>,
|
||||
recovery_key: Option<String>,
|
||||
zeroclaw_dir: Option<PathBuf>,
|
||||
) -> 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<PathBuf> {
|
||||
@ -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<MatrixSdkClient> {
|
||||
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::<MatrixSdkClient, anyhow::Error>(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<MatrixSdkClient> {
|
||||
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<MatrixSdkClient> {
|
||||
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<String> {
|
||||
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![],
|
||||
);
|
||||
|
||||
@ -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()),
|
||||
)),
|
||||
});
|
||||
|
||||
@ -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<String>,
|
||||
/// Optional Matrix user ID (e.g. `"@bot:matrix.org"`).
|
||||
#[serde(default)]
|
||||
pub user_id: Option<String>,
|
||||
@ -3140,6 +3142,16 @@ pub struct MatrixConfig {
|
||||
pub room_id: String,
|
||||
/// Allowed Matrix user IDs. Empty = deny all.
|
||||
pub allowed_users: Vec<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -3909,8 +3909,6 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||
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<ChannelsConfig> {
|
||||
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<ChannelsConfig> {
|
||||
device_id: detected_device_id,
|
||||
room_id,
|
||||
allowed_users,
|
||||
password,
|
||||
recovery_key,
|
||||
});
|
||||
}
|
||||
ChannelMenuChoice::Signal => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user