support DM target via 'dm: @user:server' frontmatter, finds or creates DM room

This commit is contained in:
Damocles 2026-04-30 21:35:48 +02:00
parent 26d0e07199
commit aa4ed13518

View file

@ -550,7 +550,21 @@ async fn process_loop(state: Arc<Mutex<DaemonState>>, client: Client) {
match invoke_claude(&room_id, &room_name, &chat_msgs, seen_idx, &model).await { match invoke_claude(&room_id, &room_name, &chat_msgs, seen_idx, &model).await {
Ok(Some(response)) => { Ok(Some(response)) => {
if let Some(target_room) = client.get_room(&response.room) { let target_room = match &response.target {
ResponseTarget::Room(rid) => client.get_room(rid),
ResponseTarget::Dm(user) => match find_or_create_dm(&client, user).await {
Ok(r) => Some(r),
Err(e) => {
tracing::error!(user = %user, "failed to get/create DM: {e}");
None
}
},
};
let target_label = match &response.target {
ResponseTarget::Room(rid) => rid.to_string(),
ResponseTarget::Dm(user) => format!("dm:{user}"),
};
if let Some(target_room) = target_room {
let content = RoomMessageEventContent::text_plain(&response.body); let content = RoomMessageEventContent::text_plain(&response.body);
match target_room.send(content).await { match target_room.send(content).await {
Ok(_) => { Ok(_) => {
@ -560,7 +574,7 @@ async fn process_loop(state: Arc<Mutex<DaemonState>>, client: Client) {
state.last_shown.insert(room_id.clone(), eid); state.last_shown.insert(room_id.clone(), eid);
} }
tracing::info!( tracing::info!(
room = %response.room, target = %target_label,
"sent response ({} budget remaining)", "sent response ({} budget remaining)",
state.rate_budget state.rate_budget
); );
@ -570,7 +584,7 @@ async fn process_loop(state: Arc<Mutex<DaemonState>>, client: Client) {
Err(e) => tracing::error!("failed to send: {e}"), Err(e) => tracing::error!("failed to send: {e}"),
} }
} else { } else {
tracing::warn!(room = %response.room, "target room not found"); tracing::warn!(target = %target_label, "target not available");
} }
} }
Ok(None) => { Ok(None) => {
@ -590,6 +604,22 @@ async fn process_loop(state: Arc<Mutex<DaemonState>>, client: Client) {
} }
} }
/// Find an existing DM room with the given user, or create one.
async fn find_or_create_dm(client: &Client, user_id: &UserId) -> anyhow::Result<Room> {
for room in client.joined_rooms() {
if room.is_direct().await.unwrap_or(false)
&& room
.direct_targets()
.iter()
.any(|t| t.as_str() == user_id.as_str())
{
return Ok(room);
}
}
tracing::info!(user = %user_id, "creating new DM room");
Ok(client.create_dm(user_id).await?)
}
async fn send_read_receipt(room: &Room, event_id: Option<OwnedEventId>) { async fn send_read_receipt(room: &Room, event_id: Option<OwnedEventId>) {
let Some(eid) = event_id else { let Some(eid) = event_id else {
return; return;
@ -685,8 +715,13 @@ async fn fetch_message(
)) ))
} }
enum ResponseTarget {
Room(OwnedRoomId),
Dm(OwnedUserId),
}
struct ClaudeResponse { struct ClaudeResponse {
room: OwnedRoomId, target: ResponseTarget,
body: String, body: String,
} }
@ -804,19 +839,31 @@ fn parse_response(raw: &str, default_room: &OwnedRoomId) -> Option<ClaudeRespons
return None; return None;
} }
let room = frontmatter // dm: takes precedence over room: if both set
let dm = frontmatter
.lines() .lines()
.find(|l| l.starts_with("room:")) .find(|l| l.starts_with("dm:"))
.and_then(|l| l.strip_prefix("room:")) .and_then(|l| l.strip_prefix("dm:"))
.and_then(|r| r.trim().parse().ok()) .and_then(|r| r.trim().parse::<OwnedUserId>().ok());
.unwrap_or_else(|| default_room.clone());
let target = if let Some(user) = dm {
ResponseTarget::Dm(user)
} else {
let room = frontmatter
.lines()
.find(|l| l.starts_with("room:"))
.and_then(|l| l.strip_prefix("room:"))
.and_then(|r| r.trim().parse().ok())
.unwrap_or_else(|| default_room.clone());
ResponseTarget::Room(room)
};
if body.is_empty() { if body.is_empty() {
return None; return None;
} }
return Some(ClaudeResponse { return Some(ClaudeResponse {
room, target,
body: body.to_owned(), body: body.to_owned(),
}); });
} }
@ -827,7 +874,7 @@ fn parse_response(raw: &str, default_room: &OwnedRoomId) -> Option<ClaudeRespons
} }
Some(ClaudeResponse { Some(ClaudeResponse {
room: default_room.clone(), target: ResponseTarget::Room(default_room.clone()),
body: trimmed.to_owned(), body: trimmed.to_owned(),
}) })
} }
@ -840,11 +887,18 @@ mod tests {
"!test:example.com".parse().unwrap() "!test:example.com".parse().unwrap()
} }
fn assert_room(resp: &ClaudeResponse, expected: &str) {
match &resp.target {
ResponseTarget::Room(r) => assert_eq!(r.as_str(), expected),
ResponseTarget::Dm(_) => panic!("expected room target, got dm"),
}
}
#[test] #[test]
fn parse_frontmatter_response() { fn parse_frontmatter_response() {
let raw = "---\nroom: !other:server\n---\nhello world"; let raw = "---\nroom: !other:server\n---\nhello world";
let resp = parse_response(raw, &test_room()).unwrap(); let resp = parse_response(raw, &test_room()).unwrap();
assert_eq!(resp.room.as_str(), "!other:server"); assert_room(&resp, "!other:server");
assert_eq!(resp.body, "hello world"); assert_eq!(resp.body, "hello world");
} }
@ -858,7 +912,7 @@ mod tests {
fn parse_plain_response() { fn parse_plain_response() {
let raw = "just a message"; let raw = "just a message";
let resp = parse_response(raw, &test_room()).unwrap(); let resp = parse_response(raw, &test_room()).unwrap();
assert_eq!(resp.room, test_room()); assert_room(&resp, "!test:example.com");
assert_eq!(resp.body, "just a message"); assert_eq!(resp.body, "just a message");
} }
@ -872,6 +926,27 @@ mod tests {
fn parse_default_room() { fn parse_default_room() {
let raw = "---\n---\nhello"; let raw = "---\n---\nhello";
let resp = parse_response(raw, &test_room()).unwrap(); let resp = parse_response(raw, &test_room()).unwrap();
assert_eq!(resp.room, test_room()); assert_room(&resp, "!test:example.com");
}
#[test]
fn parse_dm_response() {
let raw = "---\ndm: @alice:example.com\n---\nhi alice";
let resp = parse_response(raw, &test_room()).unwrap();
match &resp.target {
ResponseTarget::Dm(u) => assert_eq!(u.as_str(), "@alice:example.com"),
ResponseTarget::Room(_) => panic!("expected dm target"),
}
assert_eq!(resp.body, "hi alice");
}
#[test]
fn parse_dm_takes_precedence_over_room() {
let raw = "---\nroom: !other:server\ndm: @bob:example.com\n---\nhello";
let resp = parse_response(raw, &test_room()).unwrap();
match &resp.target {
ResponseTarget::Dm(u) => assert_eq!(u.as_str(), "@bob:example.com"),
ResponseTarget::Room(_) => panic!("expected dm target"),
}
} }
} }