feat(#1685): surface attachments in read_room + add download_file
This commit is contained in:
parent
6490fc422e
commit
73c53c7d4a
4 changed files with 147 additions and 12 deletions
|
|
@ -138,6 +138,19 @@ struct ReadRoomArgs {
|
|||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct DownloadFileArgs {
|
||||
/// Matrix room id (`!abc:server`) or canonical alias (`#name:server`).
|
||||
room: String,
|
||||
/// Event id of the attachment message (from `read_room`, where media
|
||||
/// events show a `[file: …]` / `[image: …]` marker).
|
||||
event_id: String,
|
||||
/// Optional absolute destination path. Omit to write a temp file
|
||||
/// named after the attachment; the returned `path` is where to read it.
|
||||
#[serde(default)]
|
||||
dest_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ListInvitesArgs {}
|
||||
|
||||
|
|
@ -380,6 +393,24 @@ impl MatrixBridge {
|
|||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Download a media attachment from a matrix message to a local \
|
||||
file and return its path. `room` is a room id/alias; `event_id` is the \
|
||||
attachment message (read_room shows media as `[file: …]`/`[image: …]`); \
|
||||
optional `dest_path` overrides the temp destination. The read-side \
|
||||
counterpart of send_file."
|
||||
)]
|
||||
async fn download_file(&self, Parameters(args): Parameters<DownloadFileArgs>) -> String {
|
||||
render(
|
||||
round_trip(DaemonRequest::DownloadFile {
|
||||
room: args.room,
|
||||
event_id: args.event_id,
|
||||
dest_path: args.dest_path,
|
||||
})
|
||||
.await,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler(instructions = "Matrix client for an agent on a hyperhive swarm. Use \
|
||||
|
|
|
|||
|
|
@ -106,12 +106,26 @@ fn extract_body(event: &matrix_sdk::ruma::events::AnyTimelineEvent) -> String {
|
|||
match event {
|
||||
AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(ev)) => ev
|
||||
.as_original()
|
||||
.map_or_else(String::new, |orig| match &orig.content.msgtype {
|
||||
MessageType::Text(t) => t.body.clone(),
|
||||
MessageType::Notice(n) => n.body.clone(),
|
||||
MessageType::Emote(e) => format!("* {}", e.body),
|
||||
_ => String::new(),
|
||||
}),
|
||||
.map_or_else(String::new, |orig| attachment_marker(&orig.content.msgtype)),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort plain-text body / attachment marker for a message
|
||||
/// `MessageType`. Text-like messages return their body; media messages
|
||||
/// (`m.file` / `m.image` / `m.audio` / `m.video`) return a
|
||||
/// `[file: name]`-style marker so an agent reading the room sees that an
|
||||
/// attachment is present (and can fetch it with `download_file`); all
|
||||
/// other types return "".
|
||||
fn attachment_marker(msgtype: &MessageType) -> String {
|
||||
match msgtype {
|
||||
MessageType::Text(t) => t.body.clone(),
|
||||
MessageType::Notice(n) => n.body.clone(),
|
||||
MessageType::Emote(e) => format!("* {}", e.body),
|
||||
MessageType::File(f) => format!("[file: {}]", f.body),
|
||||
MessageType::Image(i) => format!("[image: {}]", i.body),
|
||||
MessageType::Audio(a) => format!("[audio: {}]", a.body),
|
||||
MessageType::Video(v) => format!("[video: {}]", v.body),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -124,12 +138,7 @@ fn extract_body_sync(event: &matrix_sdk::ruma::events::AnySyncTimelineEvent) ->
|
|||
match event {
|
||||
AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(ev)) => ev
|
||||
.as_original()
|
||||
.map_or_else(String::new, |orig| match &orig.content.msgtype {
|
||||
MessageType::Text(t) => t.body.clone(),
|
||||
MessageType::Notice(n) => n.body.clone(),
|
||||
MessageType::Emote(e) => format!("* {}", e.body),
|
||||
_ => String::new(),
|
||||
}),
|
||||
.map_or_else(String::new, |orig| attachment_marker(&orig.content.msgtype)),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -609,6 +618,85 @@ pub async fn read_room(client: &Client, room_ref: &str, limit: Option<usize>) ->
|
|||
DaemonResponse::ok(&events)
|
||||
}
|
||||
|
||||
/// Download the media attachment carried by `event_id` in `room` and
|
||||
/// write it to a local file, returning the path. Resolves the event,
|
||||
/// extracts the `MediaSource` from its `m.file` / `m.image` / `m.audio`
|
||||
/// / `m.video` content, fetches the bytes via the media API, and writes
|
||||
/// them to `dest_path` (or a temp file named after the attachment when
|
||||
/// omitted). The agent then reads the file from the returned path.
|
||||
pub async fn download_file(
|
||||
client: &Client,
|
||||
room_ref: &str,
|
||||
event_id: &str,
|
||||
dest_path: Option<&str>,
|
||||
) -> DaemonResponse {
|
||||
use matrix_sdk::media::{MediaFormat, MediaRequestParameters};
|
||||
use matrix_sdk::ruma::events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent};
|
||||
|
||||
let room = match resolve_room(client, room_ref).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let eid: OwnedEventId = match event_id.parse() {
|
||||
Ok(e) => e,
|
||||
Err(e) => return DaemonResponse::error(format!("invalid event_id {event_id}: {e}")),
|
||||
};
|
||||
let ev = match room.event(&eid, None).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => return DaemonResponse::error(format!("fetch event {eid}: {e}")),
|
||||
};
|
||||
let parsed = match ev.raw().deserialize() {
|
||||
Ok(p) => p,
|
||||
Err(e) => return DaemonResponse::error(format!("deserialize event {eid}: {e}")),
|
||||
};
|
||||
// Pull the media source + filename from the message content.
|
||||
let AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(msg)) = parsed
|
||||
else {
|
||||
return DaemonResponse::error(format!("event {eid} is not a room message"));
|
||||
};
|
||||
let Some(orig) = msg.as_original() else {
|
||||
return DaemonResponse::error(format!("event {eid} is redacted"));
|
||||
};
|
||||
let (source, filename) = match &orig.content.msgtype {
|
||||
MessageType::File(f) => (f.source.clone(), f.body.clone()),
|
||||
MessageType::Image(i) => (i.source.clone(), i.body.clone()),
|
||||
MessageType::Audio(a) => (a.source.clone(), a.body.clone()),
|
||||
MessageType::Video(v) => (v.source.clone(), v.body.clone()),
|
||||
_ => {
|
||||
return DaemonResponse::error(format!(
|
||||
"event {eid} carries no file/image/audio/video attachment"
|
||||
));
|
||||
}
|
||||
};
|
||||
let req = MediaRequestParameters {
|
||||
source,
|
||||
format: MediaFormat::File,
|
||||
};
|
||||
let bytes = match client.media().get_media_content(&req, true).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return DaemonResponse::error(format!("download media for {eid}: {e}")),
|
||||
};
|
||||
let dest = if let Some(p) = dest_path {
|
||||
std::path::PathBuf::from(p)
|
||||
} else {
|
||||
let name = std::path::Path::new(&filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or("matrix-attachment");
|
||||
std::env::temp_dir().join(name)
|
||||
};
|
||||
if let Err(e) = tokio::fs::write(&dest, &bytes).await {
|
||||
return DaemonResponse::error(format!("write {}: {e}", dest.display()));
|
||||
}
|
||||
DaemonResponse::ok(&serde_json::json!({
|
||||
"path": dest.display().to_string(),
|
||||
"filename": filename,
|
||||
"bytes": bytes.len(),
|
||||
"room_id": room.room_id().to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Return the number of joined rooms with at least one unread
|
||||
/// notification according to the server-side push notification counts
|
||||
/// cached by the matrix-sdk client.
|
||||
|
|
|
|||
|
|
@ -86,6 +86,17 @@ pub enum DaemonRequest {
|
|||
#[serde(rename = "read_room")]
|
||||
ReadRoom { room: String, limit: Option<usize> },
|
||||
|
||||
/// Download the media attachment carried by `event_id` in `room`
|
||||
/// and write it to a local file (`dest_path`, or a temp file named
|
||||
/// after the attachment when omitted), returning the path. The
|
||||
/// read-side counterpart of `send_file`.
|
||||
#[serde(rename = "download_file")]
|
||||
DownloadFile {
|
||||
room: String,
|
||||
event_id: String,
|
||||
dest_path: Option<String>,
|
||||
},
|
||||
|
||||
/// List rooms this agent has been invited to but not yet joined.
|
||||
/// Returns each room's id, canonical alias (when present), and
|
||||
/// display name.
|
||||
|
|
|
|||
|
|
@ -95,6 +95,11 @@ async fn dispatch(req: DaemonRequest, client: &Client) -> DaemonResponse {
|
|||
}
|
||||
DaemonRequest::ListRoomMembers { room } => handlers::list_room_members(client, &room).await,
|
||||
DaemonRequest::ReadRoom { room, limit } => handlers::read_room(client, &room, limit).await,
|
||||
DaemonRequest::DownloadFile {
|
||||
room,
|
||||
event_id,
|
||||
dest_path,
|
||||
} => handlers::download_file(client, &room, &event_id, dest_path.as_deref()).await,
|
||||
DaemonRequest::UnreadCount => handlers::unread_count(client),
|
||||
DaemonRequest::UnreadSummary => handlers::unread_summary(client).await,
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue