From f6e1651dff7dddba804cd0e2057a49704e810c19 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 15 Jun 2026 10:16:16 +0200 Subject: [PATCH] feat(#1671): reject matrix send when room has unread messages --- hive-matrix-mcp/src/bin/mcp.rs | 12 +++++++++--- hive-matrix-mcp/src/handlers.rs | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/hive-matrix-mcp/src/bin/mcp.rs b/hive-matrix-mcp/src/bin/mcp.rs index 0dc0e48f..8616414e 100644 --- a/hive-matrix-mcp/src/bin/mcp.rs +++ b/hive-matrix-mcp/src/bin/mcp.rs @@ -168,7 +168,9 @@ impl MatrixBridge { #[tool( description = "Post a plain-text or markdown message to a matrix room. \ `room` is either a room id (!abc:server) or alias (#name:server). \ - Returns the new event id." + Returns the new event id. Rejected with a hint if the room still has \ + unread messages — read_room then mark_read the latest event first so \ + you don't talk over messages you haven't seen." )] async fn send_message(&self, Parameters(args): Parameters) -> String { render( @@ -181,7 +183,9 @@ impl MatrixBridge { } #[tool(description = "Open (or reuse) a direct message room with `user_id` \ - (@user:server) and post `body` to it.")] + (@user:server) and post `body` to it. If the DM room already exists \ + and has unread messages, the send is rejected with a hint — read_room \ + then mark_read the latest event first.")] async fn send_dm(&self, Parameters(args): Parameters) -> String { render( round_trip(DaemonRequest::SendDm { @@ -209,7 +213,9 @@ impl MatrixBridge { } #[tool(description = "Reply to a specific matrix event in a room, threaded \ - via m.in_reply_to. Returns the reply's event id.")] + via m.in_reply_to. Returns the reply's event id. Rejected with a hint \ + if the room still has unread messages — read_room then mark_read the \ + latest event first.")] async fn send_reply(&self, Parameters(args): Parameters) -> String { render( round_trip(DaemonRequest::SendReply { diff --git a/hive-matrix-mcp/src/handlers.rs b/hive-matrix-mcp/src/handlers.rs index cd38835b..e8095458 100644 --- a/hive-matrix-mcp/src/handlers.rs +++ b/hive-matrix-mcp/src/handlers.rs @@ -134,11 +134,39 @@ fn extract_body_sync(event: &matrix_sdk::ruma::events::AnySyncTimelineEvent) -> } } +/// Refuse to post into a room the agent hasn't caught up on. Returns +/// `Some(error)` with a helpful hint when the room still has unread +/// notifications (the agent must `read_room` then `mark_read` the +/// latest event first), or `None` when the send may proceed. +/// +/// Read-state is the matrix unread-notification count, the same signal +/// the wake path and `get_loose_ends` use, so "caught up" here means +/// exactly what those surfaces mean. Reactions and `mark_read` are not +/// gated — only message-posting tools (`send_message`, `send_reply`, +/// `send_dm`) so an agent can't talk over messages it hasn't seen. +fn unread_guard(room: &matrix_sdk::Room) -> Option { + let count = room.unread_notification_counts().notification_count; + if count == 0 { + return None; + } + let label = room + .canonical_alias() + .map_or_else(|| room.room_id().to_string(), |a| a.to_string()); + Some(DaemonResponse::error(format!( + "refusing to send: {count} unread message(s) in {label}. \ + use read_room to view them, then mark_read the latest event before \ + sending so you don't talk over messages you haven't seen." + ))) +} + 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, Err(e) => return e, }; + if let Some(reject) = unread_guard(&room) { + return reject; + } let content = RoomMessageEventContent::text_markdown(body); match room.send(content).await { Ok(resp) => DaemonResponse::ok(&serde_json::json!({ @@ -169,6 +197,9 @@ pub async fn send_dm(client: &Client, user_id: &str, body: &str) -> DaemonRespon Err(e) => return DaemonResponse::error(format!("create_dm {uid}: {e}")), }, }; + if let Some(reject) = unread_guard(&room) { + return reject; + } let content = RoomMessageEventContent::text_markdown(body); match room.send(content).await { Ok(resp) => DaemonResponse::ok(&serde_json::json!({ @@ -214,6 +245,9 @@ pub async fn send_reply( Ok(r) => r, Err(e) => return e, }; + if let Some(reject) = unread_guard(&room) { + return reject; + } let eid: OwnedEventId = match event_id.parse() { Ok(e) => e, Err(e) => return DaemonResponse::error(format!("invalid event_id {event_id}: {e}")),