feat(#1671): reject matrix send when room has unread messages

This commit is contained in:
damocles 2026-06-15 10:16:16 +02:00 committed by mara
commit f6e1651dff
2 changed files with 43 additions and 3 deletions

View file

@ -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<DaemonResponse> {
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}")),