diff --git a/Cargo.lock b/Cargo.lock index 28a1a7d2..001ca1df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1378,6 +1378,8 @@ dependencies = [ "futures-util", "hive-sh4re", "matrix-sdk", + "mime", + "mime_guess", "reqwest", "rmcp", "schemars", diff --git a/hive-matrix-mcp/Cargo.toml b/hive-matrix-mcp/Cargo.toml index 9f74495a..5dd9d32c 100644 --- a/hive-matrix-mcp/Cargo.toml +++ b/hive-matrix-mcp/Cargo.toml @@ -11,6 +11,8 @@ anyhow.workspace = true futures-util.workspace = true hive-sh4re.workspace = true matrix-sdk.workspace = true +mime = "0.3" +mime_guess = "2" reqwest.workspace = true rmcp.workspace = true schemars.workspace = true diff --git a/hive-matrix-mcp/src/bin/mcp.rs b/hive-matrix-mcp/src/bin/mcp.rs index 8616414e..778e5eec 100644 --- a/hive-matrix-mcp/src/bin/mcp.rs +++ b/hive-matrix-mcp/src/bin/mcp.rs @@ -79,6 +79,32 @@ struct SendDmArgs { body: String, } +#[derive(Debug, Deserialize, JsonSchema)] +struct SendFileArgs { + /// Matrix room id (`!abc:server`) or canonical alias (`#name:server`). + room: String, + /// Absolute path to a local file readable by the agent (e.g. a built + /// PDF under the agent's workspace). MIME type is inferred from the + /// extension. 50 MiB cap. + path: String, + /// Optional caption, sent as a follow-up text message in the room. + #[serde(default)] + caption: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct SendFileDmArgs { + /// Matrix user id of the recipient (`@user:server`). DM room is + /// created if one doesn't already exist. + user_id: String, + /// Absolute path to a local file readable by the agent. MIME type is + /// inferred from the extension. 50 MiB cap. + path: String, + /// Optional caption, sent as a follow-up text message in the DM. + #[serde(default)] + caption: Option, +} + #[derive(Debug, Deserialize, JsonSchema)] struct SendReactionArgs { room: String, @@ -196,6 +222,42 @@ impl MatrixBridge { ) } + #[tool( + description = "Upload a local file and post it as an attachment to a matrix \ + room. `room` is a room id (!abc:server) or alias (#name:server); `path` \ + is an absolute path to a local file (MIME inferred from extension, 50 MiB \ + cap); optional `caption` is sent as a follow-up message. Rejected if the \ + room has unread messages — read_room then mark_read first." + )] + async fn send_file(&self, Parameters(args): Parameters) -> String { + render( + round_trip(DaemonRequest::SendFile { + room: args.room, + path: args.path, + caption: args.caption, + }) + .await, + ) + } + + #[tool( + description = "Open (or reuse) a DM with `user_id` (@user:server) and upload \ + a local file as an attachment. `path` is an absolute local file path (MIME \ + inferred, 50 MiB cap); optional `caption` is sent as a follow-up message. \ + If the DM room already exists with unread messages, the send is rejected \ + — read_room then mark_read first." + )] + async fn send_file_dm(&self, Parameters(args): Parameters) -> String { + render( + round_trip(DaemonRequest::SendFileDm { + user_id: args.user_id, + path: args.path, + caption: args.caption, + }) + .await, + ) + } + #[tool( description = "React to a specific matrix event with an emoji or short \ string `key`. Matrix-spec annotation; renders as a reaction in \ diff --git a/hive-matrix-mcp/src/handlers.rs b/hive-matrix-mcp/src/handlers.rs index eff7bda9..e010f949 100644 --- a/hive-matrix-mcp/src/handlers.rs +++ b/hive-matrix-mcp/src/handlers.rs @@ -184,25 +184,33 @@ pub async fn send_message(client: &Client, room_ref: &str, body: &str) -> Daemon } } +/// Find the existing DM room with `user_id` or create one. Shared by +/// `send_dm` and `send_file_dm`. `direct_targets()` (cached state) is +/// used rather than the async `is_direct()`. +async fn resolve_or_create_dm( + client: &Client, + user_id: &str, +) -> Result { + let uid: OwnedUserId = user_id + .parse() + .map_err(|e| DaemonResponse::error(format!("invalid user_id {user_id}: {e}")))?; + if let Some(room) = client + .joined_rooms() + .into_iter() + .find(|r| r.direct_targets().iter().any(|t| t.as_str() == uid.as_str())) + { + return Ok(room); + } + client + .create_dm(&uid) + .await + .map_err(|e| DaemonResponse::error(format!("create_dm {uid}: {e}"))) +} + pub async fn send_dm(client: &Client, user_id: &str, body: &str) -> DaemonResponse { - let uid: OwnedUserId = match user_id.parse() { - Ok(u) => u, - Err(e) => return DaemonResponse::error(format!("invalid user_id {user_id}: {e}")), - }; - // Find existing DM or create one. - let room = client.joined_rooms().into_iter().find(|r| { - // is_direct() is async; check direct_targets() instead which - // reads from cached state. - r.direct_targets() - .iter() - .any(|t| t.as_str() == uid.as_str()) - }); - let room = match room { - Some(r) => r, - None => match client.create_dm(&uid).await { - Ok(r) => r, - Err(e) => return DaemonResponse::error(format!("create_dm {uid}: {e}")), - }, + let room = match resolve_or_create_dm(client, user_id).await { + Ok(r) => r, + Err(e) => return e, }; if let Some(reject) = unread_guard(&room) { return reject; @@ -212,12 +220,106 @@ pub async fn send_dm(client: &Client, user_id: &str, body: &str) -> DaemonRespon Ok(resp) => DaemonResponse::ok(&serde_json::json!({ "event_id": resp.event_id.to_string(), "room_id": room.room_id().to_string(), - "user_id": uid.to_string(), + "user_id": user_id, })), - Err(e) => DaemonResponse::error(format!("send DM to {uid}: {e}")), + Err(e) => DaemonResponse::error(format!("send DM to {user_id}: {e}")), } } +/// Maximum file size accepted by `send_file` / `send_file_dm`. +const MAX_UPLOAD_BYTES: u64 = 50 * 1024 * 1024; + +/// Read a local file and post it as a matrix attachment to `room`, +/// inferring the MIME type from the file extension. An optional +/// `caption` is sent as a best-effort follow-up text message (the +/// attachment has already landed, so a caption failure is non-fatal). +/// Shared by `send_file` and `send_file_dm`. +async fn upload_attachment( + room: &matrix_sdk::Room, + path: &str, + caption: Option<&str>, +) -> DaemonResponse { + let p = std::path::Path::new(path); + let meta = match tokio::fs::metadata(p).await { + Ok(m) => m, + Err(e) => return DaemonResponse::error(format!("stat {path}: {e}")), + }; + if !meta.is_file() { + return DaemonResponse::error(format!("{path} is not a regular file")); + } + if meta.len() > MAX_UPLOAD_BYTES { + return DaemonResponse::error(format!( + "{path} is {} bytes, over the {MAX_UPLOAD_BYTES}-byte upload cap", + meta.len() + )); + } + let data = match tokio::fs::read(p).await { + Ok(d) => d, + Err(e) => return DaemonResponse::error(format!("read {path}: {e}")), + }; + let filename = p + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_owned(); + let content_type: mime::Mime = mime_guess::from_path(p).first_or_octet_stream(); + let resp = match room + .send_attachment( + filename.clone(), + &content_type, + data, + matrix_sdk::attachment::AttachmentConfig::new(), + ) + .await + { + Ok(r) => r, + Err(e) => { + return DaemonResponse::error(format!("send attachment to {}: {e}", room.room_id())); + } + }; + if let Some(c) = caption.filter(|c| !c.is_empty()) { + let _ = room.send(RoomMessageEventContent::text_markdown(c)).await; + } + DaemonResponse::ok(&serde_json::json!({ + "event_id": resp.event_id.to_string(), + "room_id": room.room_id().to_string(), + "filename": filename, + "mime": content_type.essence_str(), + })) +} + +pub async fn send_file( + client: &Client, + room_ref: &str, + path: &str, + caption: Option<&str>, +) -> DaemonResponse { + let room = match resolve_room(client, room_ref).await { + Ok(r) => r, + Err(e) => return e, + }; + if let Some(reject) = unread_guard(&room) { + return reject; + } + upload_attachment(&room, path, caption).await +} + +pub async fn send_file_dm( + client: &Client, + user_id: &str, + path: &str, + caption: Option<&str>, +) -> DaemonResponse { + let room = match resolve_or_create_dm(client, user_id).await { + Ok(r) => r, + Err(e) => return e, + }; + if let Some(reject) = unread_guard(&room) { + return reject; + } + upload_attachment(&room, path, caption).await +} + pub async fn send_reaction( client: &Client, room_ref: &str, diff --git a/hive-matrix-mcp/src/protocol.rs b/hive-matrix-mcp/src/protocol.rs index bd71884f..64c4392e 100644 --- a/hive-matrix-mcp/src/protocol.rs +++ b/hive-matrix-mcp/src/protocol.rs @@ -27,6 +27,26 @@ pub enum DaemonRequest { #[serde(rename = "send_dm")] SendDm { user_id: String, body: String }, + /// Upload a local file and post it as an attachment to `room` + /// (id or alias). `caption`, when set, is sent as a follow-up + /// text message in the same room. + #[serde(rename = "send_file")] + SendFile { + room: String, + path: String, + caption: Option, + }, + + /// Open (or reuse) a DM with `user_id` and post a local file as an + /// attachment. `caption`, when set, is sent as a follow-up text + /// message in the DM. + #[serde(rename = "send_file_dm")] + SendFileDm { + user_id: String, + path: String, + caption: Option, + }, + /// React to a specific event with an emoji `key`. Matrix-spec /// `m.reaction` annotation. #[serde(rename = "send_reaction")] diff --git a/hive-matrix-mcp/src/socket.rs b/hive-matrix-mcp/src/socket.rs index 0f09b876..8ba5a5e2 100644 --- a/hive-matrix-mcp/src/socket.rs +++ b/hive-matrix-mcp/src/socket.rs @@ -65,6 +65,16 @@ async fn dispatch(req: DaemonRequest, client: &Client) -> DaemonResponse { handlers::send_message(client, &room, &body).await } DaemonRequest::SendDm { user_id, body } => handlers::send_dm(client, &user_id, &body).await, + DaemonRequest::SendFile { + room, + path, + caption, + } => handlers::send_file(client, &room, &path, caption.as_deref()).await, + DaemonRequest::SendFileDm { + user_id, + path, + caption, + } => handlers::send_file_dm(client, &user_id, &path, caption.as_deref()).await, DaemonRequest::SendReaction { room, event_id,