Compare commits

...
Author SHA1 Message Date
damocles
801b886a4c drop stale send_file_dm doc refs (per review) 2026-06-15 17:59:02 +02:00
damocles
07941421c1 replace send_file_dm with open_dm resolver (per review) 2026-06-15 17:59:02 +02:00
damocles
19f1fa4f28 rustfmt send_file handlers 2026-06-15 17:59:02 +02:00
damocles
cbbd6f3d6e feat(#1681): add matrix send_file + send_file_dm tools 2026-06-15 17:59:02 +02:00
6 changed files with 198 additions and 18 deletions

2
Cargo.lock generated
View file

@ -1378,6 +1378,8 @@ dependencies = [
"futures-util",
"hive-sh4re",
"matrix-sdk",
"mime",
"mime_guess",
"reqwest",
"rmcp",
"schemars",

View file

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

View file

@ -79,6 +79,26 @@ 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<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct OpenDmArgs {
/// Matrix user id (`@user:server`) to open a DM with. The DM room is
/// created if one doesn't already exist.
user_id: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SendReactionArgs {
room: String,
@ -196,6 +216,37 @@ 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<SendFileArgs>) -> String {
render(
round_trip(DaemonRequest::SendFile {
room: args.room,
path: args.path,
caption: args.caption,
})
.await,
)
}
#[tool(description = "Resolve (find-or-create) the DM room with `user_id` \
(@user:server) and return its room id, without sending anything. Use the \
returned room id with the room-based tools (`send_file`, `send_message`, \
) to deliver into the DM there is no per-tool DM variant.")]
async fn open_dm(&self, Parameters(args): Parameters<OpenDmArgs>) -> String {
render(
round_trip(DaemonRequest::OpenDm {
user_id: args.user_id,
})
.await,
)
}
#[tool(
description = "React to a specific matrix event with an emoji or short \
string `key`. Matrix-spec annotation; renders as a reaction in \

View file

@ -184,25 +184,33 @@ pub async fn send_message(client: &Client, room_ref: &str, body: &str) -> Daemon
}
}
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.
/// Find the existing DM room with `user_id` or create one. Shared by
/// `send_dm` and `open_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<matrix_sdk::Room, DaemonResponse> {
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())
});
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}")),
},
}) {
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 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,105 @@ 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`.
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).
/// Used by `send_file`.
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
}
/// Resolve (find-or-create) the DM room with `user_id` and return its
/// room id without sending anything. The caller then uses the room-based
/// tools (`send_file`, `send_message`, …) against that id — so there is
/// no per-tool `_dm` variant.
pub async fn open_dm(client: &Client, user_id: &str) -> DaemonResponse {
let room = match resolve_or_create_dm(client, user_id).await {
Ok(r) => r,
Err(e) => return e,
};
DaemonResponse::ok(&serde_json::json!({
"room_id": room.room_id().to_string(),
"user_id": user_id,
}))
}
pub async fn send_reaction(
client: &Client,
room_ref: &str,

View file

@ -27,6 +27,24 @@ 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<String>,
},
/// Resolve (find-or-create) the DM room with `user_id` and return
/// its room id, without sending anything. Lets a caller obtain the
/// DM room id and then use the room-based tools (`send_file`,
/// `send_message`, …) against it — so there is no per-tool `_dm`
/// variant.
#[serde(rename = "open_dm")]
OpenDm { user_id: String },
/// React to a specific event with an emoji `key`. Matrix-spec
/// `m.reaction` annotation.
#[serde(rename = "send_reaction")]

View file

@ -65,6 +65,12 @@ 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::OpenDm { user_id } => handlers::open_dm(client, &user_id).await,
DaemonRequest::SendReaction {
room,
event_id,