diff --git a/hive-matrix-mcp/src/handlers.rs b/hive-matrix-mcp/src/handlers.rs index ab0b96c4..07b9a2dc 100644 --- a/hive-matrix-mcp/src/handlers.rs +++ b/hive-matrix-mcp/src/handlers.rs @@ -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, } /// 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 { + 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) -> 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 = msgs .chunk .iter() @@ -631,7 +656,7 @@ pub async fn read_room(client: &Client, room_ref: &str, limit: Option) -> 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) -> origin_server_ts, event_type, body, + in_reply_to_event_id: extract_in_reply_to(&parsed), }) }) .collect();