Watch
0
0
Fork
You've already forked hyperhive
0

hive-matrix-mcp: decrypt timeline events in the wake path, same as read_room

This commit is contained in:
damocles 2026-10-04 18:58:45 +02:00 • committed by mara
commit be61059f15

View file

@ -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<matrix_sdk::ruma::events::AnyTimelineEvent> {
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<matrix_sdk::deserialized_responses::TimelineEvent> {
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,
))
}