diff --git a/hive-matrix-mcp/src/handlers.rs b/hive-matrix-mcp/src/handlers.rs index 5edf00d5..6aa2ccf4 100644 --- a/hive-matrix-mcp/src/handlers.rs +++ b/hive-matrix-mcp/src/handlers.rs @@ -281,22 +281,6 @@ async fn room_unread_state( .map(|(id, _)| id); let is_encrypted = room.encryption_state().is_encrypted(); - // UTD-gated body, same rule `to_timeline_event` uses for `read_room`: - // an encrypted room's genuinely-undecryptable event gets the sentinel; - // everything else (plaintext rooms, and successfully-decrypted events - // in an encrypted one) gets its real extracted body. Without this an - // encrypted room's raw ciphertext extracted as "" for every event, so - // two different unread messages produced the identical summary and - // never re-armed the wake. - let body_of = |raw: &matrix_sdk::deserialized_responses::TimelineEvent, - parsed: &matrix_sdk::ruma::events::AnySyncTimelineEvent| { - if is_encrypted && raw.kind.is_utd() { - "[unable to decrypt]".to_owned() - } else { - extract_body_sync(parsed) - } - }; - let events = recent_events(room).await; for raw in &events { let Ok(event) = raw.raw().deserialize() else { @@ -318,7 +302,7 @@ async fn room_unread_state( if event.sender().as_str() == own_user_id.as_str() { return None; } - let body = body_of(raw, &event); + let body = utd_gated_body(is_encrypted, raw.kind.is_utd(), &event); return Some((event.event_id().to_owned(), event.sender().to_owned(), body)); } // Exhausted the lookback window without hitting the receipt or an @@ -334,7 +318,7 @@ async fn room_unread_state( if newest.sender().as_str() == own_user_id.as_str() { return None; } - let body = body_of(newest_raw, &newest); + let body = utd_gated_body(is_encrypted, newest_raw.kind.is_utd(), &newest); Some(( newest.event_id().to_owned(), newest.sender().to_owned(), @@ -342,6 +326,32 @@ async fn room_unread_state( )) } +/// UTD-gated body, same rule `to_timeline_event` uses for `read_room`: an +/// encrypted room's genuinely-undecryptable event gets a sentinel that +/// still varies per event; everything else (plaintext rooms, and +/// successfully-decrypted events in an encrypted one) gets its real +/// extracted body. +/// +/// The sentinel can't be a constant string. Two different UTD events +/// (the normal e2ee failure mode — a new device, a missing megolm +/// session, late-arriving keys) would otherwise produce byte-identical +/// summaries, and the harness's content-diff upsert would never re-wake +/// on the second one — the same silent-deafness failure as an undecrypted +/// room reporting an empty body for every event, just with a different +/// unchanging text. Folding the event id into the sentinel is cheap and +/// needs no new dedup-key plumbing. +fn utd_gated_body( + is_encrypted: bool, + is_utd: bool, + parsed: &matrix_sdk::ruma::events::AnySyncTimelineEvent, +) -> String { + if is_encrypted && is_utd { + format!("[unable to decrypt] ({})", parsed.event_id()) + } else { + extract_body_sync(parsed) + } +} + pub async fn send_message(client: &Client, room_ref: &str, body: &str) -> DaemonResponse { let room = match resolve_room(client, room_ref).await { Ok(r) => r, @@ -1042,3 +1052,52 @@ pub async fn unread_summary(client: &Client) -> DaemonResponse { let rooms = collect_unread(client).await; DaemonResponse::ok(&rooms) } + +#[cfg(test)] +mod utd_body_tests { + use super::utd_gated_body; + + /// Parses a minimal `m.room.message` sync event with the given event + /// id and body, enough for `utd_gated_body`'s plaintext path + /// (`extract_body_sync`) and the UTD path (which only reads the event + /// id) alike. + fn text_event(event_id: &str, body: &str) -> matrix_sdk::ruma::events::AnySyncTimelineEvent { + serde_json::from_value(serde_json::json!({ + "type": "m.room.message", + "event_id": event_id, + "sender": "@alice:example.org", + "origin_server_ts": 1, + "content": {"msgtype": "m.text", "body": body}, + })) + .expect("valid minimal m.room.message") + } + + #[test] + fn two_utd_events_get_different_bodies() { + let a = utd_gated_body(true, true, &text_event("$a:example.org", "ciphertext a")); + let b = utd_gated_body(true, true, &text_event("$b:example.org", "ciphertext b")); + assert_ne!( + a, b, + "two different undecryptable events must not collapse to the \ + same summary text, or the harness's content-diff upsert never \ + re-wakes on the second one" + ); + assert!(a.contains("$a:example.org")); + assert!(b.contains("$b:example.org")); + } + + #[test] + fn decrypted_event_in_an_encrypted_room_uses_its_real_body() { + let got = utd_gated_body(true, false, &text_event("$a:example.org", "hello")); + assert_eq!(got, "hello"); + } + + #[test] + fn plaintext_room_ignores_the_utd_flag() { + // `is_utd` can transiently fire in a non-encrypted room while the + // SDK finishes reclassifying a freshly-synced cache row (see + // `to_timeline_event`'s doc comment) — `is_encrypted` gates it. + let got = utd_gated_body(false, true, &text_event("$a:example.org", "hello")); + assert_eq!(got, "hello"); + } +}