From be61059f15baa29e6c43e12e71a0afb9394a72e0 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 4 Oct 2026 18:58:45 +0200 Subject: [PATCH] hive-matrix-mcp: decrypt timeline events in the wake path, same as read_room --- hive-matrix-mcp/src/handlers.rs | 100 ++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 42 deletions(-) diff --git a/hive-matrix-mcp/src/handlers.rs b/hive-matrix-mcp/src/handlers.rs index 22629e3d..5edf00d5 100644 --- a/hive-matrix-mcp/src/handlers.rs +++ b/hive-matrix-mcp/src/handlers.rs @@ -99,22 +99,6 @@ async fn resolve_room( .ok_or_else(|| DaemonResponse::error(format!("room {room_id} not joined"))) } -/// Best-effort plain-text body for a (non-sync) timeline event. -/// Returns "" for non-text events (state changes, reactions, -/// redactions) — claude can still see the `event_type` field to -/// disambiguate. `AnyTimelineEvent` (not `AnySync...`) because -/// `read_room` pulls events via the `/messages` endpoint which -/// returns the full-form variant. -fn extract_body(event: &matrix_sdk::ruma::events::AnyTimelineEvent) -> String { - use matrix_sdk::ruma::events::{AnyMessageLikeEvent, AnyTimelineEvent}; - match event { - AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(ev)) => ev - .as_original() - .map_or_else(String::new, |orig| attachment_marker(&orig.content.msgtype)), - _ => String::new(), - } -} - /// Best-effort plain-text body / attachment marker for a message /// `MessageType`. Text-like messages return their body; media messages /// (`m.file` / `m.image` / `m.audio` / `m.video`) return a @@ -134,9 +118,13 @@ fn attachment_marker(msgtype: &MessageType) -> String { } } -/// Same as [`extract_body`] but for the `AnySyncTimelineEvent` variant +/// Best-effort plain-text body for the `AnySyncTimelineEvent` variant /// returned by `TimelineEvent::raw()` (the sdk-level decrypted form from -/// `room.messages()`). +/// `room.messages()`). Returns "" for non-text events (state changes, +/// reactions, redactions) — claude can still see the `event_type` field +/// to disambiguate. The one body-extractor in this module: everything +/// reading timeline events goes through `room.messages()` now, not a raw +/// `/messages` call, so there's no separate non-sync form to extract from. fn extract_body_sync(event: &matrix_sdk::ruma::events::AnySyncTimelineEvent) -> String { use matrix_sdk::ruma::events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent}; match event { @@ -207,22 +195,25 @@ const UNREAD_LOOKBACK: u32 = 50; /// first, of any type (redactions/state/reactions included — /// `room_unread_state` filters by type itself). Empty for a genuinely /// empty room or on any request failure. +/// +/// `room.messages()`, not a raw `/messages` call: the SDK method +/// transparently decrypts in an encrypted room (same path `read_room` +/// uses), where a raw call returns undecryptable `m.room.encrypted` +/// ciphertext. That mattered in practice — every unread event in an +/// encrypted room produced the identical empty body, so +/// `format_unread_summary` never changed text between two genuinely +/// different unread messages and the harness's content-diff upsert saw +/// no change to wake on. async fn recent_events( - client: &Client, room: &matrix_sdk::Room, -) -> Vec { - use matrix_sdk::ruma::api::Direction; - use matrix_sdk::ruma::api::client::message::get_message_events; - let mut req = - get_message_events::v3::Request::new(room.room_id().to_owned(), Direction::Backward); - req.limit = matrix_sdk::ruma::UInt::from(UNREAD_LOOKBACK); - let Ok(resp) = client.send(req).await else { +) -> Vec { + use matrix_sdk::room::MessagesOptions; + let mut opts = MessagesOptions::backward(); + opts.limit = matrix_sdk::ruma::UInt::from(UNREAD_LOOKBACK); + let Ok(resp) = room.messages(opts).await else { return Vec::new(); }; resp.chunk - .iter() - .filter_map(|raw| raw.deserialize().ok()) - .collect() } /// Whether `event` carries actor intent, as opposed to noise nobody @@ -236,9 +227,12 @@ async fn recent_events( /// redundant re-emit is the one thing this function exists to filter — /// an earlier version of this filter over-corrected by excluding whole /// event categories instead of just that one type; narrower is right. -fn is_intentional(event: &matrix_sdk::ruma::events::AnyTimelineEvent) -> bool { - use matrix_sdk::ruma::events::{AnyStateEvent, AnyTimelineEvent}; - !matches!(event, AnyTimelineEvent::State(AnyStateEvent::SpaceChild(_))) +fn is_intentional(event: &matrix_sdk::ruma::events::AnySyncTimelineEvent) -> bool { + use matrix_sdk::ruma::events::{AnySyncStateEvent, AnySyncTimelineEvent}; + !matches!( + event, + AnySyncTimelineEvent::State(AnySyncStateEvent::SpaceChild(_)) + ) } /// Whether `room` carries content the agent hasn't caught up on. @@ -286,8 +280,31 @@ async fn room_unread_state( .flatten() .map(|(id, _)| id); - let events = recent_events(client, room).await; - for event in &events { + 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 { + // Unparseable — same as the old filter-then-scan behaviour: + // skip it rather than let it match the read receipt or count + // as the fallback's "newest". + continue; + }; if read_event_id.as_deref() == Some(event.event_id()) { // Scanned back to the agent's own read receipt without // hitting anything intentional first — proven read, not @@ -295,17 +312,14 @@ async fn room_unread_state( // range we've fully walked. return None; } - if !is_intentional(event) { + if !is_intentional(&event) { continue; } if event.sender().as_str() == own_user_id.as_str() { return None; } - return Some(( - event.event_id().to_owned(), - event.sender().to_owned(), - extract_body(event), - )); + let body = body_of(raw, &event); + return Some((event.event_id().to_owned(), event.sender().to_owned(), body)); } // Exhausted the lookback window without hitting the receipt or an // intentional event — genuinely unknown (the receipt is further @@ -315,14 +329,16 @@ async fn room_unread_state( // false "read" risks silently swallowing a real message. Expected // to be rare now that the state-churn source this fix targets is // itself bounded. - let newest = events.first()?; + let newest_raw = events.first()?; + let newest = newest_raw.raw().deserialize().ok()?; if newest.sender().as_str() == own_user_id.as_str() { return None; } + let body = body_of(newest_raw, &newest); Some(( newest.event_id().to_owned(), newest.sender().to_owned(), - extract_body(newest), + body, )) }