fix(#2468,#2473): matrix read_room exposes in_reply_to + gates UTD sentinel on encryption

This commit is contained in:
damocles 2026-07-15 15:55:30 +02:00 committed by mara
commit 741852a8b4

View file

@ -63,6 +63,11 @@ pub struct TimelineEvent {
/// (text / notice / emote); empty for events without a textual
/// body (state changes, reactions, etc.).
pub body: String,
/// Event id of the message this replies to (`m.relates_to` →
/// `m.in_reply_to`), including the reply fallback a thread message
/// carries. `None` for top-level (non-reply) messages.
#[serde(skip_serializing_if = "Option::is_none")]
pub in_reply_to_event_id: Option<String>,
}
/// Resolve a room reference (id `!abc:server` OR alias `#name:server`)
@ -143,6 +148,23 @@ fn extract_body_sync(event: &matrix_sdk::ruma::events::AnySyncTimelineEvent) ->
}
}
/// Extract the event id this message replies to, from its `m.relates_to`.
/// Handles both a direct `m.in_reply_to` reply and the reply fallback a
/// thread message carries. Returns `None` for non-reply messages and for
/// non-`m.room.message` events.
fn extract_in_reply_to(event: &matrix_sdk::ruma::events::AnySyncTimelineEvent) -> Option<String> {
use matrix_sdk::ruma::events::room::message::Relation;
use matrix_sdk::ruma::events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent};
let AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(ev)) = event else {
return None;
};
match ev.as_original()?.content.relates_to.as_ref()? {
Relation::Reply { in_reply_to } => Some(in_reply_to.event_id.to_string()),
Relation::Thread(thread) => thread.in_reply_to.as_ref().map(|r| r.event_id.to_string()),
_ => None,
}
}
/// Human-facing label for a room: its canonical alias when set,
/// otherwise the raw room id. Used wherever a room is named in
/// agent-facing text (wake bodies, loose-ends, the unread guard).
@ -615,13 +637,16 @@ pub async fn read_room(client: &Client, room_ref: &str, limit: Option<usize>) ->
let mut opts = MessagesOptions::backward();
opts.limit = matrix_sdk::ruma::UInt::from(limit_val);
// room.messages() transparently decrypts events in encrypted rooms.
// UTD (unable to decrypt) events surface via `ev.kind.is_utd()` and
// get a sentinel body so claude knows decryption failed rather than
// seeing the raw encrypted blob.
// UTD (unable to decrypt) events surface via `ev.kind.is_utd()`, but
// that flag can transiently fire in a NON-encrypted room when the SDK
// hasn't finished reclassifying a freshly-synced `m.room.encrypted`
// cache row — so gate the sentinel on the room actually being
// encrypted to avoid a false `[unable to decrypt]` on plaintext.
let msgs = match room.messages(opts).await {
Ok(m) => m,
Err(e) => return DaemonResponse::error(format!("messages: {e}")),
};
let is_encrypted = room.encryption_state().is_encrypted();
let events: Vec<TimelineEvent> = msgs
.chunk
.iter()
@ -631,7 +656,7 @@ pub async fn read_room(client: &Client, room_ref: &str, limit: Option<usize>) ->
let sender = parsed.sender().to_string();
let origin_server_ts: i64 = parsed.origin_server_ts().0.into();
let event_type = parsed.event_type().to_string();
let body = if ev.kind.is_utd() {
let body = if is_encrypted && ev.kind.is_utd() {
"[unable to decrypt]".to_owned()
} else {
extract_body_sync(&parsed)
@ -642,6 +667,7 @@ pub async fn read_room(client: &Client, room_ref: &str, limit: Option<usize>) ->
origin_server_ts,
event_type,
body,
in_reply_to_event_id: extract_in_reply_to(&parsed),
})
})
.collect();