feat(#2479): add from/until event-id cursors to read_room
This commit is contained in:
parent
d90504b427
commit
001b1dae37
4 changed files with 189 additions and 46 deletions
|
|
@ -203,6 +203,17 @@ struct ReadRoomArgs {
|
||||||
/// Maximum events to return (default 50, max 200). Newest first.
|
/// Maximum events to return (default 50, max 200). Newest first.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
|
/// Anchor at this event id and read *forward* from it: returns the anchor
|
||||||
|
/// plus the `limit - 1` events after it (chronologically newer). Use to
|
||||||
|
/// continue reading from a known event. Mutually exclusive with `until`.
|
||||||
|
#[serde(default)]
|
||||||
|
from: Option<String>,
|
||||||
|
/// Anchor at this event id and read *backward* to it: returns the anchor
|
||||||
|
/// plus the `limit - 1` events before it (into the past). Use to page
|
||||||
|
/// older context around a known event (e.g. the target of a reply).
|
||||||
|
/// Mutually exclusive with `from`.
|
||||||
|
#[serde(default)]
|
||||||
|
until: Option<String>,
|
||||||
/// Matrix account to act as (a `name` from `hyperhive.matrixAccounts`).
|
/// Matrix account to act as (a `name` from `hyperhive.matrixAccounts`).
|
||||||
/// Omit to use the agent's primary account.
|
/// Omit to use the agent's primary account.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -518,9 +529,15 @@ impl MatrixBridge {
|
||||||
render(call(args.account, DaemonOp::ListRoomMembers { room: args.room }).await)
|
render(call(args.account, DaemonOp::ListRoomMembers { room: args.room }).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(description = "Read the most recent N events from a matrix room \
|
#[tool(description = "Read events from a matrix room (default 50, max 200), \
|
||||||
(default 50, max 200). Returns each event's id, sender, timestamp, \
|
newest first. Returns each event's id, sender, timestamp, type, \
|
||||||
type, and best-effort plain-text body.")]
|
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` \
|
||||||
|
= an event id to read forward from that event (anchor + newer \
|
||||||
|
events), or `until` = an event id to read backward to it (anchor + \
|
||||||
|
older events) — e.g. to fetch the context around a reply target \
|
||||||
|
outside the current window. `from` and `until` are mutually \
|
||||||
|
exclusive.")]
|
||||||
async fn read_room(&self, Parameters(args): Parameters<ReadRoomArgs>) -> String {
|
async fn read_room(&self, Parameters(args): Parameters<ReadRoomArgs>) -> String {
|
||||||
render(
|
render(
|
||||||
call(
|
call(
|
||||||
|
|
@ -528,6 +545,8 @@ impl MatrixBridge {
|
||||||
DaemonOp::ReadRoom {
|
DaemonOp::ReadRoom {
|
||||||
room: args.room,
|
room: args.room,
|
||||||
limit: args.limit,
|
limit: args.limit,
|
||||||
|
from: args.from,
|
||||||
|
until: args.until,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await,
|
.await,
|
||||||
|
|
|
||||||
|
|
@ -626,51 +626,160 @@ pub async fn invite_user(client: &Client, room_ref: &str, user_id: &str) -> Daem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn read_room(client: &Client, room_ref: &str, limit: Option<usize>) -> DaemonResponse {
|
/// Map a matrix-sdk timeline event to the JSON DTO, applying the
|
||||||
|
/// encryption-gated UTD sentinel. Returns `None` when the raw event can't
|
||||||
|
/// be deserialized. Shared by `read_room`'s plain-timeline and the
|
||||||
|
/// event-anchored (`from` / `until`) paths.
|
||||||
|
///
|
||||||
|
/// `room.messages()` / `event_with_context` transparently decrypt events in
|
||||||
|
/// encrypted rooms. 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.
|
||||||
|
fn to_timeline_event(
|
||||||
|
ev: &matrix_sdk::deserialized_responses::TimelineEvent,
|
||||||
|
is_encrypted: bool,
|
||||||
|
) -> Option<TimelineEvent> {
|
||||||
|
let parsed = ev.raw().deserialize().ok()?;
|
||||||
|
let body = if is_encrypted && ev.kind.is_utd() {
|
||||||
|
"[unable to decrypt]".to_owned()
|
||||||
|
} else {
|
||||||
|
extract_body_sync(&parsed)
|
||||||
|
};
|
||||||
|
Some(TimelineEvent {
|
||||||
|
event_id: parsed.event_id().to_string(),
|
||||||
|
sender: parsed.sender().to_string(),
|
||||||
|
origin_server_ts: parsed.origin_server_ts().0.into(),
|
||||||
|
event_type: parsed.event_type().to_string(),
|
||||||
|
body,
|
||||||
|
in_reply_to_event_id: extract_in_reply_to(&parsed),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read room timeline events. With neither cursor set, returns the last
|
||||||
|
/// `limit` events (newest-first) — the default. `from` / `until` anchor the
|
||||||
|
/// read at an event id (mutually exclusive):
|
||||||
|
/// - `until = event_id` → the anchor plus the `limit - 1` events *before* it
|
||||||
|
/// (page into the past from a known event).
|
||||||
|
/// - `from = event_id` → the anchor plus the `limit - 1` events *after* it
|
||||||
|
/// (continue reading forward from a known point).
|
||||||
|
///
|
||||||
|
/// Both cursors resolve the anchor via the `/context` endpoint
|
||||||
|
/// (`event_with_context`) to get a pagination token, then page precisely in
|
||||||
|
/// the requested direction — matrix `/messages` `from` is an opaque token,
|
||||||
|
/// not an event id, so plain `messages()` can't anchor at an event. All
|
||||||
|
/// three modes return events newest-first for a consistent shape.
|
||||||
|
pub async fn read_room(
|
||||||
|
client: &Client,
|
||||||
|
room_ref: &str,
|
||||||
|
limit: Option<usize>,
|
||||||
|
from: Option<String>,
|
||||||
|
until: Option<String>,
|
||||||
|
) -> DaemonResponse {
|
||||||
use matrix_sdk::room::MessagesOptions;
|
use matrix_sdk::room::MessagesOptions;
|
||||||
|
use matrix_sdk::ruma::UInt;
|
||||||
|
|
||||||
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,
|
||||||
};
|
};
|
||||||
let limit_val = u32::try_from(limit.unwrap_or(50).min(200)).unwrap_or(50);
|
if from.is_some() && until.is_some() {
|
||||||
let mut opts = MessagesOptions::backward();
|
return DaemonResponse::error(
|
||||||
opts.limit = matrix_sdk::ruma::UInt::from(limit_val);
|
"read_room: `from` and `until` are mutually exclusive \
|
||||||
// room.messages() transparently decrypts events in encrypted rooms.
|
(they page opposite directions off the anchor event)"
|
||||||
// UTD (unable to decrypt) events surface via `ev.kind.is_utd()`, but
|
.to_owned(),
|
||||||
// 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
|
// Clamp to [1, 200]; keep it in u32 so every `UInt` conversion below is
|
||||||
// encrypted to avoid a false `[unable to decrypt]` on plaintext.
|
// infallible (`limit_val - 1` can't underflow — the clamp floor is 1).
|
||||||
let msgs = match room.messages(opts).await {
|
let limit_val: u32 = u32::try_from(limit.unwrap_or(50))
|
||||||
Ok(m) => m,
|
.unwrap_or(50)
|
||||||
Err(e) => return DaemonResponse::error(format!("messages: {e}")),
|
.clamp(1, 200);
|
||||||
};
|
|
||||||
let is_encrypted = room.encryption_state().is_encrypted();
|
let is_encrypted = room.encryption_state().is_encrypted();
|
||||||
let events: Vec<TimelineEvent> = msgs
|
|
||||||
.chunk
|
let events: Vec<TimelineEvent> = if let Some(anchor_ref) = from.as_deref().or(until.as_deref())
|
||||||
.iter()
|
{
|
||||||
.filter_map(|ev| {
|
let eid: OwnedEventId = match anchor_ref.parse() {
|
||||||
let parsed = ev.raw().deserialize().ok()?;
|
Ok(e) => e,
|
||||||
let event_id = parsed.event_id().to_string();
|
Err(e) => return DaemonResponse::error(format!("invalid event_id {anchor_ref}: {e}")),
|
||||||
let sender = parsed.sender().to_string();
|
};
|
||||||
let origin_server_ts: i64 = parsed.origin_server_ts().0.into();
|
// context_size 0: no context events, so the returned tokens point
|
||||||
let event_type = parsed.event_type().to_string();
|
// immediately around the anchor and the follow-up page is contiguous.
|
||||||
let body = if is_encrypted && ev.kind.is_utd() {
|
let ctx = match room
|
||||||
"[unable to decrypt]".to_owned()
|
.event_with_context(&eid, false, UInt::from(0u32), None)
|
||||||
} else {
|
.await
|
||||||
extract_body_sync(&parsed)
|
{
|
||||||
};
|
Ok(c) => c,
|
||||||
Some(TimelineEvent {
|
Err(e) => return DaemonResponse::error(format!("event_with_context {eid}: {e}")),
|
||||||
event_id,
|
};
|
||||||
sender,
|
let Some(anchor) = ctx.event else {
|
||||||
origin_server_ts,
|
return DaemonResponse::error(format!("event {eid} not found in room"));
|
||||||
event_type,
|
};
|
||||||
body,
|
let side_limit = UInt::from(limit_val - 1);
|
||||||
in_reply_to_event_id: extract_in_reply_to(&parsed),
|
if from.is_some() {
|
||||||
})
|
// Forward (newer) from the anchor. The forward chunk is
|
||||||
})
|
// oldest-first; reverse to newest-first with the anchor (oldest of
|
||||||
.collect();
|
// the slice) last. When the anchor is at the live edge there is no
|
||||||
|
// forward token — return the anchor alone rather than pass
|
||||||
|
// `from = None` to messages(), which would page from room *start*.
|
||||||
|
let mut out: Vec<TimelineEvent> = Vec::new();
|
||||||
|
if let Some(token) = ctx.next_batch_token {
|
||||||
|
let mut opts = MessagesOptions::forward();
|
||||||
|
opts.from = Some(token);
|
||||||
|
opts.limit = side_limit;
|
||||||
|
let newer = match room.messages(opts).await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => return DaemonResponse::error(format!("messages (from): {e}")),
|
||||||
|
};
|
||||||
|
out.extend(
|
||||||
|
newer
|
||||||
|
.chunk
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.filter_map(|ev| to_timeline_event(ev, is_encrypted)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out.extend(to_timeline_event(&anchor, is_encrypted));
|
||||||
|
out
|
||||||
|
} else {
|
||||||
|
// Backward (older) to the anchor. Backward chunk is already
|
||||||
|
// newest-first; the anchor is the newest event, so it leads. When
|
||||||
|
// the anchor is the oldest event there is no backward token —
|
||||||
|
// return the anchor alone rather than pass `from = None`, which
|
||||||
|
// would page from room *end* (the newest events).
|
||||||
|
let mut out: Vec<TimelineEvent> = to_timeline_event(&anchor, is_encrypted)
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
if let Some(token) = ctx.prev_batch_token {
|
||||||
|
let mut opts = MessagesOptions::backward();
|
||||||
|
opts.from = Some(token);
|
||||||
|
opts.limit = side_limit;
|
||||||
|
let older = match room.messages(opts).await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => return DaemonResponse::error(format!("messages (until): {e}")),
|
||||||
|
};
|
||||||
|
out.extend(
|
||||||
|
older
|
||||||
|
.chunk
|
||||||
|
.iter()
|
||||||
|
.filter_map(|ev| to_timeline_event(ev, is_encrypted)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mut opts = MessagesOptions::backward();
|
||||||
|
opts.limit = UInt::from(limit_val);
|
||||||
|
let msgs = match room.messages(opts).await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => return DaemonResponse::error(format!("messages: {e}")),
|
||||||
|
};
|
||||||
|
msgs.chunk
|
||||||
|
.iter()
|
||||||
|
.filter_map(|ev| to_timeline_event(ev, is_encrypted))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
DaemonResponse::ok(&events)
|
DaemonResponse::ok(&events)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,11 +114,21 @@ pub enum DaemonOp {
|
||||||
#[serde(rename = "list_room_members")]
|
#[serde(rename = "list_room_members")]
|
||||||
ListRoomMembers { room: String },
|
ListRoomMembers { room: String },
|
||||||
|
|
||||||
/// Read the last `limit` events from a room's timeline. Caller
|
/// Read events from a room's timeline. Caller gets each event's id,
|
||||||
/// gets each event's id, sender, `server_ts`, type, and body (best-
|
/// sender, `server_ts`, type, and body (best-effort plain-text extraction
|
||||||
/// effort plain-text extraction from `m.text` / `m.notice` etc.).
|
/// from `m.text` / `m.notice` etc.). With neither cursor, returns the last
|
||||||
|
/// `limit` events (newest-first). `from` / `until` anchor at an event id
|
||||||
|
/// (mutually exclusive): `until` reads the anchor + `limit-1` events before
|
||||||
|
/// it (into the past); `from` reads the anchor + `limit-1` events after it.
|
||||||
#[serde(rename = "read_room")]
|
#[serde(rename = "read_room")]
|
||||||
ReadRoom { room: String, limit: Option<usize> },
|
ReadRoom {
|
||||||
|
room: String,
|
||||||
|
limit: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
from: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
until: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
/// Download the media attachment carried by `event_id` in `room`
|
/// Download the media attachment carried by `event_id` in `room`
|
||||||
/// and write it to a local file (`dest_path`, or a temp file named
|
/// and write it to a local file (`dest_path`, or a temp file named
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,12 @@ async fn dispatch_op(op: DaemonOp, client: &Client) -> DaemonResponse {
|
||||||
handlers::invite_user(client, &room, &user_id).await
|
handlers::invite_user(client, &room, &user_id).await
|
||||||
}
|
}
|
||||||
DaemonOp::ListRoomMembers { room } => handlers::list_room_members(client, &room).await,
|
DaemonOp::ListRoomMembers { room } => handlers::list_room_members(client, &room).await,
|
||||||
DaemonOp::ReadRoom { room, limit } => handlers::read_room(client, &room, limit).await,
|
DaemonOp::ReadRoom {
|
||||||
|
room,
|
||||||
|
limit,
|
||||||
|
from,
|
||||||
|
until,
|
||||||
|
} => handlers::read_room(client, &room, limit, from, until).await,
|
||||||
DaemonOp::DownloadFile {
|
DaemonOp::DownloadFile {
|
||||||
room,
|
room,
|
||||||
event_id,
|
event_id,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue