hive-matrix-mcp: an edited message shows once, edited in place, and is not a second unread
This commit is contained in:
parent
c8eaa93268
commit
ce9332cd02
3 changed files with 237 additions and 9 deletions
|
|
@ -27,7 +27,10 @@ is what an agent reads at call time. They fall into four groups:
|
||||||
- **Reading:** `read_room`, `download_file`, `list_rooms` and
|
- **Reading:** `read_room`, `download_file`, `list_rooms` and
|
||||||
`list_room_members`. `read_room` shows media as `[file: …]`, `[image: …]`,
|
`list_room_members`. `read_room` shows media as `[file: …]`, `[image: …]`,
|
||||||
`[audio: …]` or `[video: …]` markers, and the event id of one goes to
|
`[audio: …]` or `[video: …]` markers, and the event id of one goes to
|
||||||
`download_file`.
|
`download_file`. An edited message shows once, with its latest text and
|
||||||
|
`edited: true`. The edit event doesn't appear as a second message and doesn't
|
||||||
|
count as unread. When the original is outside the window read, the edit stays
|
||||||
|
as its own event, and its `replaces_event_id` names the message it replaces.
|
||||||
- **Room membership:** `invite_user`, `list_invites`, `resolve_invite` and
|
- **Room membership:** `invite_user`, `list_invites`, `resolve_invite` and
|
||||||
`join_room`. `resolve_invite` accepts or rejects a pending invite and is the
|
`join_room`. `resolve_invite` accepts or rejects a pending invite and is the
|
||||||
path for invites; `join_room` is for a public room the agent wasn't invited to.
|
path for invites; `join_room` is for a public room the agent wasn't invited to.
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,73 @@ pub struct TimelineEvent {
|
||||||
/// carries. `None` for top-level (non-reply) messages.
|
/// carries. `None` for top-level (non-reply) messages.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub in_reply_to_event_id: Option<String>,
|
pub in_reply_to_event_id: Option<String>,
|
||||||
|
/// Set on a message that was edited: `body` then holds the latest
|
||||||
|
/// edited text, not the text first posted.
|
||||||
|
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub edited: bool,
|
||||||
|
/// Set on an edit (`m.replace`) event whose original is outside the
|
||||||
|
/// window read: the id of the message it replaces, with `body` holding
|
||||||
|
/// the new text. Edits whose original is in the window are folded into
|
||||||
|
/// it by `collapse_edits` and never appear on their own.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub replaces_event_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `(replaced event id, new body)` an edit (`m.replace`) event
|
||||||
|
/// carries, or `None` for any other event. The top-level `body` of an edit
|
||||||
|
/// is a `* `-prefixed fallback for clients that cannot render edits; the
|
||||||
|
/// text to show is in `m.new_content`.
|
||||||
|
fn replacement_of(
|
||||||
|
event: &matrix_sdk::ruma::events::AnySyncTimelineEvent,
|
||||||
|
) -> Option<(String, 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::Replacement(r) => Some((
|
||||||
|
r.event_id.to_string(),
|
||||||
|
attachment_marker(&r.new_content.msgtype),
|
||||||
|
)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold each edit into the message it replaces, so a reader sees the
|
||||||
|
/// edited text once instead of the original plus a `* ` duplicate.
|
||||||
|
/// `events` is newest first, so the first edit met for a message is its
|
||||||
|
/// latest. An edit only counts when its sender also sent the original
|
||||||
|
/// (the spec's rule); one whose original is outside the window stays as an
|
||||||
|
/// event of its own with `replaces_event_id` set.
|
||||||
|
fn collapse_edits(events: Vec<TimelineEvent>) -> Vec<TimelineEvent> {
|
||||||
|
let mut latest: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
||||||
|
let mut folded: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
for edit in &events {
|
||||||
|
let Some(target) = &edit.replaces_event_id else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let original_by_same_sender = events
|
||||||
|
.iter()
|
||||||
|
.any(|e| &e.event_id == target && e.sender == edit.sender);
|
||||||
|
if original_by_same_sender {
|
||||||
|
folded.insert(edit.event_id.clone());
|
||||||
|
latest
|
||||||
|
.entry(target.clone())
|
||||||
|
.or_insert_with(|| edit.body.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
events
|
||||||
|
.into_iter()
|
||||||
|
.filter(|e| !folded.contains(&e.event_id))
|
||||||
|
.map(|mut e| {
|
||||||
|
if let Some(body) = latest.remove(&e.event_id) {
|
||||||
|
e.body = body;
|
||||||
|
e.edited = true;
|
||||||
|
}
|
||||||
|
e
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a room reference (id `!abc:server` OR alias `#name:server`)
|
/// Resolve a room reference (id `!abc:server` OR alias `#name:server`)
|
||||||
|
|
@ -231,18 +298,21 @@ async fn recent_events(
|
||||||
/// (of every msgtype, not just `m.room.message`'s common ones),
|
/// (of every msgtype, not just `m.room.message`'s common ones),
|
||||||
/// reactions, state changes (join/leave/topic/name/etc. are all real
|
/// reactions, state changes (join/leave/topic/name/etc. are all real
|
||||||
/// activity an agent should see, mara: don't lump them in with
|
/// activity an agent should see, mara: don't lump them in with
|
||||||
/// housekeeping noise). The one deliberate exception is `m.space.child`:
|
/// housekeeping noise). The deliberate exceptions are `m.space.child`:
|
||||||
/// a periodic re-apply of an *unchanged* value still appends a timeline
|
/// a periodic re-apply of an *unchanged* value still appends a timeline
|
||||||
/// event (see [`room_unread_state`]'s doc comment), and that specific
|
/// event (see [`room_unread_state`]'s doc comment), and that specific
|
||||||
/// redundant re-emit is the one thing this function exists to filter —
|
/// redundant re-emit is the thing this function exists to filter —
|
||||||
/// an earlier version of this filter over-corrected by excluding whole
|
/// an earlier version of this filter over-corrected by excluding whole
|
||||||
/// event categories instead of just that one type; narrower is right.
|
/// event categories instead of just that one type; narrower is right —
|
||||||
|
/// and an edit (`m.replace`), which restates a message already counted.
|
||||||
fn is_intentional(event: &matrix_sdk::ruma::events::AnySyncTimelineEvent) -> bool {
|
fn is_intentional(event: &matrix_sdk::ruma::events::AnySyncTimelineEvent) -> bool {
|
||||||
use matrix_sdk::ruma::events::{AnySyncStateEvent, AnySyncTimelineEvent};
|
use matrix_sdk::ruma::events::{AnySyncStateEvent, AnySyncTimelineEvent};
|
||||||
|
// An edit (`m.replace`) is the same message again, not a new one: it
|
||||||
|
// must not count as a second unread (which would also hold up sends).
|
||||||
!matches!(
|
!matches!(
|
||||||
event,
|
event,
|
||||||
AnySyncTimelineEvent::State(AnySyncStateEvent::SpaceChild(_))
|
AnySyncTimelineEvent::State(AnySyncStateEvent::SpaceChild(_))
|
||||||
)
|
) && replacement_of(event).is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `room` carries content the agent hasn't caught up on.
|
/// Whether `room` carries content the agent hasn't caught up on.
|
||||||
|
|
@ -890,8 +960,11 @@ fn to_timeline_event(
|
||||||
is_encrypted: bool,
|
is_encrypted: bool,
|
||||||
) -> Option<TimelineEvent> {
|
) -> Option<TimelineEvent> {
|
||||||
let parsed = ev.raw().deserialize().ok()?;
|
let parsed = ev.raw().deserialize().ok()?;
|
||||||
|
let replacement = replacement_of(&parsed);
|
||||||
let body = if is_encrypted && ev.kind.is_utd() {
|
let body = if is_encrypted && ev.kind.is_utd() {
|
||||||
"[unable to decrypt]".to_owned()
|
"[unable to decrypt]".to_owned()
|
||||||
|
} else if let Some((_, new_body)) = &replacement {
|
||||||
|
new_body.clone()
|
||||||
} else {
|
} else {
|
||||||
extract_body_sync(&parsed)
|
extract_body_sync(&parsed)
|
||||||
};
|
};
|
||||||
|
|
@ -902,6 +975,8 @@ fn to_timeline_event(
|
||||||
event_type: parsed.event_type().to_string(),
|
event_type: parsed.event_type().to_string(),
|
||||||
body,
|
body,
|
||||||
in_reply_to_event_id: extract_in_reply_to(&parsed),
|
in_reply_to_event_id: extract_in_reply_to(&parsed),
|
||||||
|
edited: false,
|
||||||
|
replaces_event_id: replacement.map(|(target, _)| target),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1028,7 +1103,7 @@ pub async fn read_room(
|
||||||
.filter_map(|ev| to_timeline_event(ev, is_encrypted))
|
.filter_map(|ev| to_timeline_event(ev, is_encrypted))
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
DaemonResponse::ok(&events)
|
DaemonResponse::ok(&collapse_edits(events))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download the media attachment carried by `event_id` in `room` and
|
/// Download the media attachment carried by `event_id` in `room` and
|
||||||
|
|
@ -1222,8 +1297,8 @@ mod utd_body_tests {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod unread_tests {
|
mod unread_tests {
|
||||||
use super::{
|
use super::{
|
||||||
ParsedEvent, attachment_marker, extract_body_sync, extract_in_reply_to, is_intentional,
|
ParsedEvent, TimelineEvent, attachment_marker, collapse_edits, extract_body_sync,
|
||||||
unread_verdict,
|
extract_in_reply_to, is_intentional, replacement_of, unread_verdict,
|
||||||
};
|
};
|
||||||
use matrix_sdk::ruma::events::AnySyncTimelineEvent;
|
use matrix_sdk::ruma::events::AnySyncTimelineEvent;
|
||||||
use matrix_sdk::ruma::events::room::message::MessageType;
|
use matrix_sdk::ruma::events::room::message::MessageType;
|
||||||
|
|
@ -1311,6 +1386,153 @@ mod unread_tests {
|
||||||
assert_eq!(verdict(None, &events), None);
|
assert_eq!(verdict(None, &events), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn edit(id: &str, sender: &str, target: &str, new_body: &str) -> AnySyncTimelineEvent {
|
||||||
|
parse(json!({
|
||||||
|
"type": "m.room.message",
|
||||||
|
"event_id": id,
|
||||||
|
"sender": sender,
|
||||||
|
"origin_server_ts": 2,
|
||||||
|
"content": {
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"body": format!("* {new_body}"),
|
||||||
|
"m.new_content": {"msgtype": "m.text", "body": new_body},
|
||||||
|
"m.relates_to": {"rel_type": "m.replace", "event_id": target},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timeline(id: &str, sender: &str, body: &str, replaces: Option<&str>) -> TimelineEvent {
|
||||||
|
TimelineEvent {
|
||||||
|
event_id: id.into(),
|
||||||
|
sender: sender.into(),
|
||||||
|
origin_server_ts: 1,
|
||||||
|
event_type: "m.room.message".into(),
|
||||||
|
body: body.into(),
|
||||||
|
in_reply_to_event_id: None,
|
||||||
|
edited: false,
|
||||||
|
replaces_event_id: replaces.map(Into::into),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_edit_carries_the_new_text_and_the_id_it_replaces() {
|
||||||
|
let ev = edit(
|
||||||
|
"$e:example.org",
|
||||||
|
"@bob:example.org",
|
||||||
|
"$o:example.org",
|
||||||
|
"fixed",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
replacement_of(&ev),
|
||||||
|
Some(("$o:example.org".into(), "fixed".into()))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
replacement_of(&message("$m:example.org", "@bob:example.org", "plain")),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_edit_is_not_a_second_unread_message() {
|
||||||
|
assert!(!is_intentional(&edit(
|
||||||
|
"$e:example.org",
|
||||||
|
"@bob:example.org",
|
||||||
|
"$o:example.org",
|
||||||
|
"fixed"
|
||||||
|
)));
|
||||||
|
// The edit sits newest; the walk skips it and lands on the
|
||||||
|
// original, so the room reports one unread message, not two.
|
||||||
|
let events = [
|
||||||
|
Some(live(edit(
|
||||||
|
"$e:example.org",
|
||||||
|
"@bob:example.org",
|
||||||
|
"$o:example.org",
|
||||||
|
"fixed",
|
||||||
|
))),
|
||||||
|
Some(live(message("$o:example.org", "@bob:example.org", "typo"))),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
verdict(None, &events).map(|(id, _, _)| id),
|
||||||
|
Some("$o:example.org".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_edit_folds_into_its_original_and_the_duplicate_disappears() {
|
||||||
|
let got = collapse_edits(vec![
|
||||||
|
timeline(
|
||||||
|
"$e:example.org",
|
||||||
|
"@bob:example.org",
|
||||||
|
"fixed",
|
||||||
|
Some("$o:example.org"),
|
||||||
|
),
|
||||||
|
timeline("$other:example.org", "@amy:example.org", "unrelated", None),
|
||||||
|
timeline("$o:example.org", "@bob:example.org", "typo", None),
|
||||||
|
]);
|
||||||
|
let ids: Vec<&str> = got.iter().map(|e| e.event_id.as_str()).collect();
|
||||||
|
assert_eq!(ids, ["$other:example.org", "$o:example.org"]);
|
||||||
|
assert_eq!(got[1].body, "fixed");
|
||||||
|
assert!(got[1].edited);
|
||||||
|
assert!(!got[0].edited);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_latest_of_several_edits_wins() {
|
||||||
|
let got = collapse_edits(vec![
|
||||||
|
timeline(
|
||||||
|
"$e2:example.org",
|
||||||
|
"@bob:example.org",
|
||||||
|
"third",
|
||||||
|
Some("$o:example.org"),
|
||||||
|
),
|
||||||
|
timeline(
|
||||||
|
"$e1:example.org",
|
||||||
|
"@bob:example.org",
|
||||||
|
"second",
|
||||||
|
Some("$o:example.org"),
|
||||||
|
),
|
||||||
|
timeline("$o:example.org", "@bob:example.org", "first", None),
|
||||||
|
]);
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].body, "third");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn someone_elses_edit_does_not_rewrite_a_message() {
|
||||||
|
let got = collapse_edits(vec![
|
||||||
|
timeline(
|
||||||
|
"$e:example.org",
|
||||||
|
"@mallory:example.org",
|
||||||
|
"forged",
|
||||||
|
Some("$o:example.org"),
|
||||||
|
),
|
||||||
|
timeline("$o:example.org", "@bob:example.org", "real", None),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
got.len(),
|
||||||
|
2,
|
||||||
|
"the forged edit stays visible as its own event"
|
||||||
|
);
|
||||||
|
assert_eq!(got[1].body, "real");
|
||||||
|
assert!(!got[1].edited);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_edit_whose_original_is_out_of_the_window_stays_and_says_what_it_replaces() {
|
||||||
|
let got = collapse_edits(vec![timeline(
|
||||||
|
"$e:example.org",
|
||||||
|
"@bob:example.org",
|
||||||
|
"fixed",
|
||||||
|
Some("$old:example.org"),
|
||||||
|
)]);
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
got[0].replaces_event_id.as_deref(),
|
||||||
|
Some("$old:example.org")
|
||||||
|
);
|
||||||
|
assert_eq!(got[0].body, "fixed");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_space_child_re_emit_is_skipped_to_reach_the_real_message() {
|
fn a_space_child_re_emit_is_skipped_to_reach_the_real_message() {
|
||||||
let events = [
|
let events = [
|
||||||
|
|
|
||||||
|
|
@ -462,7 +462,10 @@ impl MatrixMcp {
|
||||||
#[tool(description = "Read events from a matrix room (default 50, max 200), \
|
#[tool(description = "Read events from a matrix room (default 50, max 200), \
|
||||||
newest first. Returns each event's id, sender, timestamp, type, \
|
newest first. Returns each event's id, sender, timestamp, type, \
|
||||||
best-effort plain-text body, and the id of the event it replies to \
|
best-effort plain-text body, and the id of the event it replies to \
|
||||||
(when any). By default returns the most recent events. Pass `from` \
|
(when any). An edited message appears once with its latest text and \
|
||||||
|
`edited: true`; the edit event itself is folded in, not listed (one \
|
||||||
|
whose original is outside the window stays, with `replaces_event_id`). \
|
||||||
|
By default returns the most recent events. Pass `from` \
|
||||||
= an event id to read forward from that event (anchor + newer \
|
= an event id to read forward from that event (anchor + newer \
|
||||||
events), or `until` = an event id to read backward to it (anchor + \
|
events), or `until` = an event id to read backward to it (anchor + \
|
||||||
older events) — e.g. to fetch the context around a reply target \
|
older events) — e.g. to fetch the context around a reply target \
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue