hive-matrix-mcp: decrypt timeline events in the wake path, same as read_room
This commit is contained in:
parent
01ec29da48
commit
be61059f15
1 changed files with 58 additions and 42 deletions
|
|
@ -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,
|
||||
))
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue