diff --git a/hive-matrix-mcp/src/handlers.rs b/hive-matrix-mcp/src/handlers.rs index 37277fa3..e1bb42d2 100644 --- a/hive-matrix-mcp/src/handlers.rs +++ b/hive-matrix-mcp/src/handlers.rs @@ -511,6 +511,19 @@ fn recipient_among( }) } +/// A bare agent name (`iris`, no `@` and no `:`) means that agent's account +/// on the caller's own homeserver, `@iris:`; anything else is +/// passed through for the normal user id parse. Lets a simple agent DM a peer +/// by the name it knows it by. +fn expand_user_id(own_server: Option<&str>, input: &str) -> String { + match own_server { + Some(server) if !input.is_empty() && !input.contains(['@', ':']) => { + format!("@{input}:{server}") + } + _ => input.to_owned(), + } +} + /// Find the existing DM room with `user_id` or create one, and say where the /// recipient stands in it. Shared by `send_dm` and `open_dm`. /// `direct_targets()` (cached state) is used rather than the async @@ -523,6 +536,8 @@ async fn resolve_or_create_dm( client: &Client, user_id: &str, ) -> Result<(matrix_sdk::Room, Recipient), DaemonResponse> { + let own = client.user_id().map(|u| u.server_name().to_string()); + let user_id = expand_user_id(own.as_deref(), user_id); let uid: OwnedUserId = user_id .parse() .map_err(|e| DaemonResponse::error(format!("invalid user_id {user_id}: {e}")))?; @@ -568,6 +583,9 @@ fn recipient_fields(recipient: Recipient) -> serde_json::Value { } pub async fn send_dm(client: &Client, user_id: &str, body: &str) -> DaemonResponse { + let own = client.user_id().map(|u| u.server_name().to_string()); + let user_id = expand_user_id(own.as_deref(), user_id); + let user_id = user_id.as_str(); let (room, recipient) = match resolve_or_create_dm(client, user_id).await { Ok(r) => r, Err(e) => return e, @@ -1321,9 +1339,9 @@ mod utd_body_tests { #[cfg(test)] mod unread_tests { use super::{ - ParsedEvent, TimelineEvent, attachment_marker, collapse_edits, extract_body_sync, - extract_in_reply_to, is_intentional, media_parts, replacement_of, safe_file_name, - unread_verdict, + ParsedEvent, TimelineEvent, attachment_marker, collapse_edits, expand_user_id, + extract_body_sync, extract_in_reply_to, is_intentional, media_parts, replacement_of, + safe_file_name, unread_verdict, }; use matrix_sdk::ruma::events::AnySyncTimelineEvent; use matrix_sdk::ruma::events::room::message::MessageType; @@ -1368,6 +1386,18 @@ mod unread_tests { .map(|(id, sender, body)| (id.to_string(), sender.to_string(), body)) } + #[test] + fn a_bare_agent_name_means_that_agent_on_the_own_homeserver() { + let own = Some("pr1ma.darkest.space"); + assert_eq!(expand_user_id(own, "iris"), "@iris:pr1ma.darkest.space"); + // Full ids and anything with a server part pass through untouched. + assert_eq!(expand_user_id(own, "@mara:matrix.org"), "@mara:matrix.org"); + assert_eq!(expand_user_id(own, "iris:other.org"), "iris:other.org"); + // No own server known, or nothing given: nothing to expand with. + assert_eq!(expand_user_id(None, "iris"), "iris"); + assert_eq!(expand_user_id(own, ""), ""); + } + #[test] fn an_empty_room_is_caught_up() { assert_eq!(verdict(None, &[]), None); diff --git a/hive-matrix-mcp/src/mcp.rs b/hive-matrix-mcp/src/mcp.rs index ff5e0646..18c46f8c 100644 --- a/hive-matrix-mcp/src/mcp.rs +++ b/hive-matrix-mcp/src/mcp.rs @@ -280,7 +280,8 @@ impl MatrixMcp { } #[tool(description = "Open (or reuse) a direct message room with `user_id` \ - (@user:server) and post `body` to it. If the DM room already exists \ + (@user:server, or just an agent's name such as `iris` for that agent \ + on this homeserver) and post `body` to it. If the DM room already exists \ and has unread messages, the send is rejected with a hint — read_room \ then mark_read the latest event first.")] async fn send_dm(&self, Parameters(args): Parameters) -> String {