Watch
0
0
Fork
You've already forked hyperhive
0

hive-matrix-mcp: vary the UTD sentinel by event id so it re-arms the wake

This commit is contained in:
damocles 2026-10-04 19:30:06 +02:00 • committed by mara
commit 6ce01ff38d

View file

@ -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");
}
}