diff --git a/hive-matrix-mcp/src/bin/mcp.rs b/hive-matrix-mcp/src/bin/mcp.rs index 8be34203..36c15fe9 100644 --- a/hive-matrix-mcp/src/bin/mcp.rs +++ b/hive-matrix-mcp/src/bin/mcp.rs @@ -138,6 +138,19 @@ struct ReadRoomArgs { limit: Option, } +#[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, +} + #[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) -> 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 \ diff --git a/hive-matrix-mcp/src/handlers.rs b/hive-matrix-mcp/src/handlers.rs index 8020dff4..d7d932c5 100644 --- a/hive-matrix-mcp/src/handlers.rs +++ b/hive-matrix-mcp/src/handlers.rs @@ -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) -> 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. diff --git a/hive-matrix-mcp/src/protocol.rs b/hive-matrix-mcp/src/protocol.rs index 58a3b231..b847c3e4 100644 --- a/hive-matrix-mcp/src/protocol.rs +++ b/hive-matrix-mcp/src/protocol.rs @@ -86,6 +86,17 @@ pub enum DaemonRequest { #[serde(rename = "read_room")] ReadRoom { room: String, limit: Option }, + /// 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, + }, + /// List rooms this agent has been invited to but not yet joined. /// Returns each room's id, canonical alias (when present), and /// display name. diff --git a/hive-matrix-mcp/src/socket.rs b/hive-matrix-mcp/src/socket.rs index 2a8a588f..78f8acd3 100644 --- a/hive-matrix-mcp/src/socket.rs +++ b/hive-matrix-mcp/src/socket.rs @@ -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, }