feat(#1681): add matrix send_file + send_file_dm tools
This commit is contained in:
parent
1b0f035a05
commit
cbbd6f3d6e
6 changed files with 218 additions and 20 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1378,6 +1378,8 @@ dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hive-sh4re",
|
"hive-sh4re",
|
||||||
"matrix-sdk",
|
"matrix-sdk",
|
||||||
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rmcp",
|
"rmcp",
|
||||||
"schemars",
|
"schemars",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ anyhow.workspace = true
|
||||||
futures-util.workspace = true
|
futures-util.workspace = true
|
||||||
hive-sh4re.workspace = true
|
hive-sh4re.workspace = true
|
||||||
matrix-sdk.workspace = true
|
matrix-sdk.workspace = true
|
||||||
|
mime = "0.3"
|
||||||
|
mime_guess = "2"
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
rmcp.workspace = true
|
rmcp.workspace = true
|
||||||
schemars.workspace = true
|
schemars.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,32 @@ struct SendDmArgs {
|
||||||
body: String,
|
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 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct SendReactionArgs {
|
struct SendReactionArgs {
|
||||||
room: String,
|
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<SendFileArgs>) -> 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<SendFileDmArgs>) -> String {
|
||||||
|
render(
|
||||||
|
round_trip(DaemonRequest::SendFileDm {
|
||||||
|
user_id: args.user_id,
|
||||||
|
path: args.path,
|
||||||
|
caption: args.caption,
|
||||||
|
})
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
description = "React to a specific matrix event with an emoji or short \
|
description = "React to a specific matrix event with an emoji or short \
|
||||||
string `key`. Matrix-spec annotation; renders as a reaction in \
|
string `key`. Matrix-spec annotation; renders as a reaction in \
|
||||||
|
|
|
||||||
|
|
@ -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<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()))
|
||||||
|
{
|
||||||
|
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 {
|
pub async fn send_dm(client: &Client, user_id: &str, body: &str) -> DaemonResponse {
|
||||||
let uid: OwnedUserId = match user_id.parse() {
|
let room = match resolve_or_create_dm(client, user_id).await {
|
||||||
Ok(u) => u,
|
Ok(r) => r,
|
||||||
Err(e) => return DaemonResponse::error(format!("invalid user_id {user_id}: {e}")),
|
Err(e) => return 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}")),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
if let Some(reject) = unread_guard(&room) {
|
if let Some(reject) = unread_guard(&room) {
|
||||||
return reject;
|
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!({
|
Ok(resp) => DaemonResponse::ok(&serde_json::json!({
|
||||||
"event_id": resp.event_id.to_string(),
|
"event_id": resp.event_id.to_string(),
|
||||||
"room_id": room.room_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(
|
pub async fn send_reaction(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
room_ref: &str,
|
room_ref: &str,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,26 @@ pub enum DaemonRequest {
|
||||||
#[serde(rename = "send_dm")]
|
#[serde(rename = "send_dm")]
|
||||||
SendDm { user_id: String, body: String },
|
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>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
},
|
||||||
|
|
||||||
/// React to a specific event with an emoji `key`. Matrix-spec
|
/// React to a specific event with an emoji `key`. Matrix-spec
|
||||||
/// `m.reaction` annotation.
|
/// `m.reaction` annotation.
|
||||||
#[serde(rename = "send_reaction")]
|
#[serde(rename = "send_reaction")]
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,16 @@ async fn dispatch(req: DaemonRequest, client: &Client) -> DaemonResponse {
|
||||||
handlers::send_message(client, &room, &body).await
|
handlers::send_message(client, &room, &body).await
|
||||||
}
|
}
|
||||||
DaemonRequest::SendDm { user_id, body } => handlers::send_dm(client, &user_id, &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 {
|
DaemonRequest::SendReaction {
|
||||||
room,
|
room,
|
||||||
event_id,
|
event_id,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue