matrix: wake and unread-guard on own read receipt, not push-rule count
This commit is contained in:
parent
d9da92de4f
commit
1192c4e4c7
2 changed files with 105 additions and 81 deletions
|
|
@ -16,7 +16,7 @@ use matrix_sdk::{
|
||||||
api::client::receipt::create_receipt::v3::ReceiptType,
|
api::client::receipt::create_receipt::v3::ReceiptType,
|
||||||
events::{
|
events::{
|
||||||
reaction::ReactionEventContent,
|
reaction::ReactionEventContent,
|
||||||
receipt::ReceiptThread,
|
receipt::{ReceiptThread, ReceiptType as LocalReceiptType},
|
||||||
relation::Annotation,
|
relation::Annotation,
|
||||||
room::message::{AddMentions, MessageType, RoomMessageEventContent},
|
room::message::{AddMentions, MessageType, RoomMessageEventContent},
|
||||||
},
|
},
|
||||||
|
|
@ -175,33 +175,98 @@ pub fn room_label(room: &matrix_sdk::Room) -> String {
|
||||||
|
|
||||||
/// Refuse to post into a room the agent hasn't caught up on. Returns
|
/// 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
|
/// `Some(error)` with a helpful hint when the room still has unread
|
||||||
/// notifications (the agent must `read_room` then `mark_read` the
|
/// content (the agent must `read_room` then `mark_read` the latest event
|
||||||
/// latest event first), or `None` when the send may proceed.
|
/// first), or `None` when the send may proceed.
|
||||||
///
|
///
|
||||||
/// Read-state is the matrix unread-notification count, the same signal
|
/// Read-state is [`room_unread_state`] — the same predicate the wake path
|
||||||
/// the wake path and `get_loose_ends` use, so "caught up" here means
|
/// and `get_loose_ends` use, so "caught up" here means exactly what those
|
||||||
/// exactly what those surfaces mean. Reactions and `mark_read` are not
|
/// surfaces mean. Reactions and `mark_read` are not gated — only
|
||||||
/// gated — only message-posting tools (`send_message`, `send_reply`,
|
/// message-posting tools (`send_message`, `send_reply`, `send_dm`) so an
|
||||||
/// `send_dm`) so an agent can't talk over messages it hasn't seen.
|
/// agent can't talk over messages it hasn't seen.
|
||||||
fn unread_guard(room: &matrix_sdk::Room) -> Option<DaemonResponse> {
|
async fn unread_guard(client: &Client, room: &matrix_sdk::Room) -> Option<DaemonResponse> {
|
||||||
let count = room.unread_notification_counts().notification_count;
|
room_unread_state(client, room).await?;
|
||||||
if count == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let label = room_label(room);
|
let label = room_label(room);
|
||||||
Some(DaemonResponse::error(format!(
|
Some(DaemonResponse::error(format!(
|
||||||
"refusing to send: {count} unread message(s) in {label}. \
|
"refusing to send: unread message(s) in {label}. \
|
||||||
use read_room to view them, then mark_read the latest event before \
|
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."
|
sending so you don't talk over messages you haven't seen."
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch the single most recent timeline event in `room`, of any type
|
||||||
|
/// (redactions/state/reactions included — identity is what
|
||||||
|
/// [`room_unread_state`] needs, not content). `None` for a genuinely
|
||||||
|
/// empty room or on any request failure.
|
||||||
|
async fn latest_event(
|
||||||
|
client: &Client,
|
||||||
|
room: &matrix_sdk::Room,
|
||||||
|
) -> Option<(OwnedEventId, OwnedUserId, String)> {
|
||||||
|
use matrix_sdk::ruma::api::Direction;
|
||||||
|
use matrix_sdk::ruma::api::client::message::get_message_events;
|
||||||
|
let mut req =
|
||||||
|
get_message_events::v3::Request::new(room.room_id().to_owned(), Direction::Backward);
|
||||||
|
req.limit = matrix_sdk::ruma::UInt::from(1u32);
|
||||||
|
let resp = client.send(req).await.ok()?;
|
||||||
|
let raw = resp.chunk.first()?;
|
||||||
|
let ev = raw.deserialize().ok()?;
|
||||||
|
let body = extract_body(&ev);
|
||||||
|
Some((ev.event_id().to_owned(), ev.sender().to_owned(), body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `room` carries content the agent hasn't caught up on.
|
||||||
|
/// `Some((event_id, sender, body))` for the latest event when it's
|
||||||
|
/// genuinely unread; `None` when the room is caught up, empty, or its
|
||||||
|
/// latest event is self-authored.
|
||||||
|
///
|
||||||
|
/// Diffs the room's own local state against the agent's own `m.read`
|
||||||
|
/// receipt instead of `unread_notification_counts()`. That count is
|
||||||
|
/// server-computed from Matrix **push rules** — a DM or a plain message
|
||||||
|
/// that doesn't match any push rule (no keyword, no @mention) can leave a
|
||||||
|
/// genuinely-unread event reporting `notification_count: 0` forever, so
|
||||||
|
/// the agent never wakes for it (confirmed live on a prior investigation:
|
||||||
|
/// the daemon had the event in its local store the whole time and still
|
||||||
|
/// reported zero — the count was never the right signal to read).
|
||||||
|
/// Comparing the latest local event id against the own-account read
|
||||||
|
/// receipt needs no push-rule semantics at all.
|
||||||
|
///
|
||||||
|
/// Self-authored latest events are never "unread" — after a daemon
|
||||||
|
/// rebuild the read receipt can lag behind the agent's own just-sent
|
||||||
|
/// message, which must not self-wake it.
|
||||||
|
async fn room_unread_state(
|
||||||
|
client: &Client,
|
||||||
|
room: &matrix_sdk::Room,
|
||||||
|
) -> Option<(OwnedEventId, OwnedUserId, String)> {
|
||||||
|
let own_user_id = client.user_id()?;
|
||||||
|
let (event_id, sender, body) = latest_event(client, room).await?;
|
||||||
|
if sender.as_str() == own_user_id.as_str() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// `load_user_receipt` takes `ruma::events::receipt::ReceiptType`
|
||||||
|
// (imported here as `LocalReceiptType`), a distinct type from the
|
||||||
|
// `ReceiptType` this module already imports for `send_single_receipt`
|
||||||
|
// (`ruma::api::client::receipt::create_receipt::v3::ReceiptType`) — two
|
||||||
|
// same-named enums from different ruma crates, not interchangeable.
|
||||||
|
let read = room
|
||||||
|
.load_user_receipt(
|
||||||
|
LocalReceiptType::Read,
|
||||||
|
ReceiptThread::Unthreaded,
|
||||||
|
own_user_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
if read.is_some_and(|(id, _)| id == event_id) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((event_id, sender, body))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn send_message(client: &Client, room_ref: &str, body: &str) -> DaemonResponse {
|
pub async fn send_message(client: &Client, room_ref: &str, body: &str) -> DaemonResponse {
|
||||||
let room = match resolve_room(client, room_ref).await {
|
let room = match resolve_room(client, room_ref).await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return e,
|
Err(e) => return e,
|
||||||
};
|
};
|
||||||
if let Some(reject) = unread_guard(&room) {
|
if let Some(reject) = unread_guard(client, &room).await {
|
||||||
return reject;
|
return reject;
|
||||||
}
|
}
|
||||||
let content = RoomMessageEventContent::text_markdown(body);
|
let content = RoomMessageEventContent::text_markdown(body);
|
||||||
|
|
@ -242,7 +307,7 @@ pub async fn send_dm(client: &Client, user_id: &str, body: &str) -> DaemonRespon
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return e,
|
Err(e) => return e,
|
||||||
};
|
};
|
||||||
if let Some(reject) = unread_guard(&room) {
|
if let Some(reject) = unread_guard(client, &room).await {
|
||||||
return reject;
|
return reject;
|
||||||
}
|
}
|
||||||
let content = RoomMessageEventContent::text_markdown(body);
|
let content = RoomMessageEventContent::text_markdown(body);
|
||||||
|
|
@ -328,7 +393,7 @@ pub async fn send_file(
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return e,
|
Err(e) => return e,
|
||||||
};
|
};
|
||||||
if let Some(reject) = unread_guard(&room) {
|
if let Some(reject) = unread_guard(client, &room).await {
|
||||||
return reject;
|
return reject;
|
||||||
}
|
}
|
||||||
upload_attachment(&room, path, caption).await
|
upload_attachment(&room, path, caption).await
|
||||||
|
|
@ -383,7 +448,7 @@ pub async fn send_reply(
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return e,
|
Err(e) => return e,
|
||||||
};
|
};
|
||||||
if let Some(reject) = unread_guard(&room) {
|
if let Some(reject) = unread_guard(client, &room).await {
|
||||||
return reject;
|
return reject;
|
||||||
}
|
}
|
||||||
let eid: OwnedEventId = match event_id.parse() {
|
let eid: OwnedEventId = match event_id.parse() {
|
||||||
|
|
@ -864,12 +929,12 @@ pub fn unread_count(client: &Client) -> DaemonResponse {
|
||||||
/// `/messages` endpoint (best-effort; failures leave `last_body` as
|
/// `/messages` endpoint (best-effort; failures leave `last_body` as
|
||||||
/// `None`). Rooms with zero unreads are omitted.
|
/// `None`). Rooms with zero unreads are omitted.
|
||||||
///
|
///
|
||||||
/// **Latency note**: each count==1 room triggers a live `/messages`
|
/// **Latency note**: each unread room triggers a live `/messages`
|
||||||
/// network request to the matrix homeserver to retrieve the message
|
/// network request to the matrix homeserver to determine its latest
|
||||||
/// body. This adds per-room round-trip latency to `get_loose_ends`
|
/// event (via [`room_unread_state`]). This adds per-room round-trip
|
||||||
/// and the wake-signal path. Acceptable in practice (rooms with
|
/// latency to `get_loose_ends` and the wake-signal path. Acceptable in
|
||||||
/// unread are few; request is best-effort), but worth bearing in
|
/// practice (rooms with unread are few; request is best-effort), but
|
||||||
/// mind if latency becomes a concern.
|
/// worth bearing in mind if latency becomes a concern.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub async fn collect_unread(client: &Client) -> Vec<crate::protocol::RoomUnread> {
|
pub async fn collect_unread(client: &Client) -> Vec<crate::protocol::RoomUnread> {
|
||||||
collect_unread_with_ids(client)
|
collect_unread_with_ids(client)
|
||||||
|
|
@ -889,74 +954,29 @@ pub async fn collect_unread_with_ids(
|
||||||
) -> Vec<(matrix_sdk::ruma::OwnedRoomId, crate::protocol::RoomUnread)> {
|
) -> Vec<(matrix_sdk::ruma::OwnedRoomId, crate::protocol::RoomUnread)> {
|
||||||
use crate::protocol::RoomUnread;
|
use crate::protocol::RoomUnread;
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let own_user_id = client.user_id();
|
|
||||||
for room in client.joined_rooms() {
|
for room in client.joined_rooms() {
|
||||||
let count =
|
let Some((_, sender, body)) = room_unread_state(client, &room).await else {
|
||||||
u32::try_from(room.unread_notification_counts().notification_count).unwrap_or(u32::MAX);
|
|
||||||
if count == 0 {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
let label = room_label(&room);
|
|
||||||
let (last_body, last_sender) = if count == 1 {
|
|
||||||
fetch_last_message(client, &room).await
|
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
};
|
};
|
||||||
// Skip a single-unread room whose newest event is the agent's own
|
let label = room_label(&room);
|
||||||
// message: after a rebuild the read receipt may not have advanced
|
let last_body = (!body.is_empty()).then(|| crate::wake::truncate_chars(&body, 100));
|
||||||
// past it yet, so `notification_count` can still report 1 — without
|
|
||||||
// this the agent self-wakes on its own message. Only reachable on the
|
|
||||||
// count==1 path, where `last_sender` is populated.
|
|
||||||
if let (Some(sender), Some(own_id)) = (&last_sender, own_user_id)
|
|
||||||
&& sender.as_str() == own_id.as_str()
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
result.push((
|
result.push((
|
||||||
room.room_id().to_owned(),
|
room.room_id().to_owned(),
|
||||||
RoomUnread {
|
RoomUnread {
|
||||||
label,
|
label,
|
||||||
count,
|
count: 1,
|
||||||
last_body,
|
last_body,
|
||||||
last_sender,
|
last_sender: Some(sender.to_string()),
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the body + sender of the most recent room message. Returns
|
/// Return per-room unread summaries. Each entry carries the sender +
|
||||||
/// `(None, None)` on any error or when the timeline contains no text
|
/// truncated body of the room's latest (unread) event when it's a
|
||||||
/// events.
|
/// text-like message; rooms whose latest unread event has no body
|
||||||
async fn fetch_last_message(
|
/// (a reaction, a state change, …) carry the room + sender only.
|
||||||
client: &Client,
|
|
||||||
room: &matrix_sdk::Room,
|
|
||||||
) -> (Option<String>, Option<String>) {
|
|
||||||
use matrix_sdk::ruma::api::Direction;
|
|
||||||
use matrix_sdk::ruma::api::client::message::get_message_events;
|
|
||||||
let mut req =
|
|
||||||
get_message_events::v3::Request::new(room.room_id().to_owned(), Direction::Backward);
|
|
||||||
req.limit = matrix_sdk::ruma::UInt::from(1u32);
|
|
||||||
let Ok(resp) = client.send(req).await else {
|
|
||||||
return (None, None);
|
|
||||||
};
|
|
||||||
for raw in &resp.chunk {
|
|
||||||
if let Ok(ev) = raw.deserialize() {
|
|
||||||
let body = extract_body(&ev);
|
|
||||||
if !body.is_empty() {
|
|
||||||
return (
|
|
||||||
Some(crate::wake::truncate_chars(&body, 100)),
|
|
||||||
Some(ev.sender().to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(None, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return per-room unread summaries. For rooms with exactly one
|
|
||||||
/// unread notification, attempts to include the sender + truncated
|
|
||||||
/// body; rooms with multiple unreads carry only the count.
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub async fn unread_summary(client: &Client) -> DaemonResponse {
|
pub async fn unread_summary(client: &Client) -> DaemonResponse {
|
||||||
let rooms = collect_unread(client).await;
|
let rooms = collect_unread(client).await;
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,14 @@ pub enum InviteAction {
|
||||||
pub struct RoomUnread {
|
pub struct RoomUnread {
|
||||||
/// Canonical alias (`#name:server`) or room id (`!id:server`).
|
/// Canonical alias (`#name:server`) or room id (`!id:server`).
|
||||||
pub label: String,
|
pub label: String,
|
||||||
/// Server-side push-notification count for this room. Always ≥ 1.
|
/// Always `1` — an exact unread-message count is no longer tracked
|
||||||
|
/// (see `handlers::room_unread_state`'s doc comment for why); the
|
||||||
|
/// agent reads the room via `read_room` for full context, so only
|
||||||
|
/// "there's something new" plus a one-message preview matters here.
|
||||||
pub count: u32,
|
pub count: u32,
|
||||||
/// Truncated body of the last message in the room. Present only when
|
/// Truncated body of the room's latest (unread) event. Present when
|
||||||
/// `count == 1` and the fetch succeeded; absent otherwise.
|
/// that event is a text-like message; absent for a reaction/state
|
||||||
|
/// event or when the fetch failed.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub last_body: Option<String>,
|
pub last_body: Option<String>,
|
||||||
/// Sender of the last message (`@user:server`). Present when
|
/// Sender of the last message (`@user:server`). Present when
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue