diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index a2750e88..0673f353 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -354,9 +354,6 @@ async fn discover_admin_room_id( /// Conduwuit/tuwunel responds with a message like: /// "Done: Password of user @user:server has been reset. The new password is: " /// -/// Handles several format variants emitted by different tuwunel / conduwuit -/// versions — "new password is:", "password is:", "changed to:", etc. -/// /// Uses [`str::to_ascii_lowercase`] for case folding — unlike `to_lowercase`, /// ASCII lowercasing is guaranteed to produce a same-byte-length string, so the /// byte offset from `find` is always a valid index into the original `bot_message` @@ -366,31 +363,16 @@ fn extract_new_password(bot_message: &str) -> Option { // `lower.find(marker)` are valid byte indices into `bot_message`. let lower = bot_message.to_ascii_lowercase(); for marker in &[ - // Explicit "is:" variants (most common in conduwuit / tuwunel): "new password is: ", "new password is:", "password is: ", "password is:", - // "changed to:" / "reset to:" / "set to:" variants: - "changed to: ", - "changed to:", - "reset to: ", - "reset to:", - "set to: ", - "set to:", - // Bare "new password:" without "is": - "new password: ", - "new password:", - // Bare "password:" as last resort (must come after more specific markers): - "password: ", - "password:", ] { if let Some(pos) = lower.find(marker) { let rest = &bot_message[pos + marker.len()..]; let rest = rest.trim_start(); - // Stop at first whitespace or newline; password must be non-empty. let end = rest - .find(char::is_whitespace) + .find(|c: char| c.is_whitespace() || c == '\n') .unwrap_or(rest.len()); let pw = rest[..end].trim(); if !pw.is_empty() { @@ -446,44 +428,15 @@ mod extract_new_password_tests { let msg = "New password is: abc123 (save it now)"; assert_eq!(extract_new_password(msg).as_deref(), Some("abc123")); } - - #[test] - fn changed_to_variant() { - let msg = "Password of user @atlas:pr1ma.darkest.space has been changed to: Xyz987!"; - assert_eq!(extract_new_password(msg).as_deref(), Some("Xyz987!")); - } - - #[test] - fn reset_to_variant() { - let msg = "Password for user @foo:bar has been reset to: hunter2"; - assert_eq!(extract_new_password(msg).as_deref(), Some("hunter2")); - } - - #[test] - fn bare_new_password_colon() { - let msg = "New password: P@ssword1"; - assert_eq!(extract_new_password(msg).as_deref(), Some("P@ssword1")); - } - - #[test] - fn bare_password_colon_last_resort() { - let msg = "Your account password: S3cr3t"; - assert_eq!(extract_new_password(msg).as_deref(), Some("S3cr3t")); - } } /// Send a command to the Matrix admin room and poll for a bot response. /// -/// Strategy: send the command, capture its `event_id`, then poll backwards -/// (`dir=b&limit=20`) on each tick. Events in a backward response are -/// newest-first; we walk the list until we find our own command event_id, -/// then stop — everything before that marker in the list is a response that -/// arrived *after* our command. We check `body` and `formatted_body` of -/// every non-self message in that window. -/// -/// This avoids forward-pagination token direction issues that occur with -/// some tuwunel builds: backward fetches are always anchored at the live -/// timeline end and need no stored token. +/// 1. Anchors at the current end-token (`dir=b&limit=1`). +/// 2. Sends `command` as an `m.text` message from the @hive admin account. +/// 3. Polls up to 15 × 1 s for a bot message that satisfies `check`. +/// 4. Calls `check(body)` on each non-self message; returns `Ok(T)` on the +/// first `Some(T)`. /// /// Generic over `T` so both password-returning and `()` callers share the loop. async fn admin_room_send_and_poll( @@ -494,7 +447,30 @@ async fn admin_room_send_and_poll( command: &str, check: impl Fn(&str) -> Option, ) -> Result { - // Send the command; record the event_id so we can use it as an anchor. + // Pagination anchor — fetch the current tail so we only read events + // that arrive *after* our command, not old history. + let msgs_back_url = format!( + "{MATRIX_HTTP}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=1" + ); + let msgs_back = client + .get(&msgs_back_url) + .bearer_auth(admin_token) + .send() + .await + .context("matrix: admin room messages (back)")? + .json::() + .await + .context("matrix: parse admin room messages response")?; + let mut from_token = msgs_back["end"] + .as_str() + .ok_or_else(|| { + anyhow::anyhow!( + "matrix: admin room messages response missing 'end' token: {msgs_back}" + ) + })? + .to_owned(); + + // Send the command. let txn_id = random_hex(8)?; let send_url = format!( "{MATRIX_HTTP}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}" @@ -510,24 +486,16 @@ async fn admin_room_send_and_poll( let body = send_resp.json::().await.unwrap_or_default(); anyhow::bail!("matrix: admin room send failed: {body}"); } - let send_json = send_resp - .json::() - .await - .unwrap_or_default(); - let our_event_id = send_json["event_id"] - .as_str() - .unwrap_or("") - .to_owned(); - // Poll for bot response: fetch the 20 most recent events (newest-first) - // on each tick. Walk the list until we hit our own command event_id; - // everything *before* that marker arrived after our command. + // Poll for bot response (up to 15 × 1 s). tuwunel can take several + // seconds to process admin-room commands during startup. let own_user_id = format!("@{HIVE_ADMIN_LOCALPART}:{server_name}"); - let poll_url = format!( - "{MATRIX_HTTP}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20" - ); for _ in 0..15_u8 { tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let from_encoded = from_token.replace(':', "%3A").replace('+', "%2B"); + let poll_url = format!( + "{MATRIX_HTTP}/_matrix/client/v3/rooms/{room_url}/messages?dir=f&from={from_encoded}&limit=10" + ); let poll_json = client .get(&poll_url) .bearer_auth(admin_token) @@ -540,35 +508,21 @@ async fn admin_room_send_and_poll( if let Some(events) = poll_json["chunk"].as_array() { for event in events { - // Stop as soon as we reach our own command — everything - // older (further into the list) predates our request. - // If our_event_id is empty (malformed PUT response), we skip - // this guard and inspect all 20 events — slight risk of a - // false match from an older response, but an acceptable fallback. - if !our_event_id.is_empty() - && event["event_id"].as_str() == Some(our_event_id.as_str()) - { - break; - } if event["type"].as_str() != Some("m.room.message") { continue; } if event["sender"].as_str() == Some(own_user_id.as_str()) { continue; } - // Check both plain body and formatted_body (HTML) — some - // admin bots put the password only in formatted_body. - let body = event["content"]["body"].as_str().unwrap_or_default(); - let formatted = event["content"]["formatted_body"] - .as_str() - .unwrap_or_default(); - for text in [body, formatted] { - if let Some(result) = check(text) { - return Ok(result); - } + let body = event["content"]["body"].as_str().unwrap_or(""); + if let Some(result) = check(body) { + return Ok(result); } } } + if let Some(end) = poll_json["end"].as_str() { + from_token = end.to_owned(); + } } anyhow::bail!(