feat(#1685): surface attachments in read_room + add download_file

This commit is contained in:
damocles 2026-06-15 18:17:35 +02:00
commit 73c53c7d4a
4 changed files with 147 additions and 12 deletions

View file

@ -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 \

View file

@ -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.

View file

@ -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.

View file

@ -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,
}