//! Per-tool dispatch — each daemon request from the MCP bridge resolves //! to one of these handlers. Returns a `DaemonResponse` shaped for the //! wire protocol (the MCP bridge unwraps `Ok { payload }` and returns //! the payload to claude; `Error { message }` becomes the tool-call //! error message claude sees). //! //! Tool surface mirrors damocles-daemon's v0 set per the operator's call: //! `send_message`, `send_dm`, `send_reaction`, `send_reply`, `mark_read`, //! `list_rooms`, `list_room_members`, `read_room`. Plus a `ping` for the //! MCP bridge's liveness probe. use matrix_sdk::{ Client, room::reply::{EnforceThread, Reply}, ruma::{ OwnedEventId, OwnedRoomId, OwnedServerName, OwnedUserId, RoomOrAliasId, api::client::receipt::create_receipt::v3::ReceiptType, events::{ reaction::ReactionEventContent, receipt::ReceiptThread, relation::Annotation, room::message::{MessageType, RoomMessageEventContent}, }, }, }; use serde::Serialize; use crate::protocol::{DaemonResponse, InviteAction}; /// JSON-shape for `list_invites`: one row per pending room invite. #[derive(Debug, Serialize)] pub struct InviteInfo { pub room_id: String, pub canonical_alias: Option, pub name: Option, } /// JSON-shape for `list_rooms`: one row per joined room. #[derive(Debug, Serialize)] pub struct RoomInfo { pub room_id: String, pub canonical_alias: Option, pub name: String, pub member_count: u64, } /// JSON-shape for `list_room_members`: one row per joined member. #[derive(Debug, Serialize)] pub struct MemberInfo { pub user_id: String, pub display_name: Option, } /// JSON-shape for `read_room`: one timeline event, flattened for /// claude consumption. #[derive(Debug, Serialize)] pub struct TimelineEvent { pub event_id: String, pub sender: String, pub origin_server_ts: i64, pub event_type: String, /// Best-effort plain-text body for `m.room.message` events /// (text / notice / emote); empty for events without a textual /// body (state changes, reactions, etc.). pub body: String, } /// Resolve a room reference (id `!abc:server` OR alias `#name:server`) /// to a joined `Room`. Returns an `Error` response if the room isn't /// joined / the reference is malformed. async fn resolve_room( client: &Client, reference: &str, ) -> Result { let parsed: &RoomOrAliasId = reference .try_into() .map_err(|e| DaemonResponse::error(format!("invalid room reference {reference}: {e}")))?; let room_id: OwnedRoomId = if parsed.is_room_id() { OwnedRoomId::try_from(reference) .map_err(|e| DaemonResponse::error(format!("invalid room_id: {e}")))? } else { client .resolve_room_alias( parsed .as_str() .try_into() .map_err(|e| DaemonResponse::error(format!("invalid alias: {e}")))?, ) .await .map(|r| r.room_id) .map_err(|e| DaemonResponse::error(format!("resolve_room_alias {reference}: {e}")))? }; client .get_room(&room_id) .ok_or_else(|| DaemonResponse::error(format!("room {room_id} not joined"))) } /// Best-effort plain-text body for a (non-sync) timeline event. /// Returns "" for non-text events (state changes, reactions, /// redactions) — claude can still see the `event_type` field to /// disambiguate. `AnyTimelineEvent` (not `AnySync...`) because /// `read_room` pulls events via the `/messages` endpoint which /// returns the full-form variant. fn extract_body(event: &matrix_sdk::ruma::events::AnyTimelineEvent) -> String { use matrix_sdk::ruma::events::{AnyMessageLikeEvent, AnyTimelineEvent}; match event { AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(ev)) => ev .as_original() .map_or_else(String::new, |orig| match &orig.content.msgtype { MessageType::Text(t) => t.body.clone(), MessageType::Notice(n) => n.body.clone(), MessageType::Emote(e) => format!("* {}", e.body), _ => String::new(), }), _ => String::new(), } } /// Same as [`extract_body`] but for the `AnySyncTimelineEvent` variant /// returned by `TimelineEvent::raw()` (the sdk-level decrypted form from /// `room.messages()`). fn extract_body_sync(event: &matrix_sdk::ruma::events::AnySyncTimelineEvent) -> String { use matrix_sdk::ruma::events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent}; match event { AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(ev)) => ev .as_original() .map_or_else(String::new, |orig| match &orig.content.msgtype { MessageType::Text(t) => t.body.clone(), MessageType::Notice(n) => n.body.clone(), MessageType::Emote(e) => format!("* {}", e.body), _ => String::new(), }), _ => String::new(), } } /// Human-facing label for a room: its canonical alias when set, /// otherwise the raw room id. Used wherever a room is named in /// agent-facing text (wake bodies, loose-ends, the unread guard). #[must_use] pub fn room_label(room: &matrix_sdk::Room) -> String { room.canonical_alias() .map_or_else(|| room.room_id().to_string(), |a| a.to_string()) } /// Refuse to post into a room the agent hasn't caught up on. Returns /// `Some(error)` with a helpful hint when the room still has unread /// notifications (the agent must `read_room` then `mark_read` the /// latest event first), or `None` when the send may proceed. /// /// Read-state is the matrix unread-notification count, the same signal /// the wake path and `get_loose_ends` use, so "caught up" here means /// exactly what those surfaces mean. Reactions and `mark_read` are not /// gated — only message-posting tools (`send_message`, `send_reply`, /// `send_dm`) so an agent can't talk over messages it hasn't seen. fn unread_guard(room: &matrix_sdk::Room) -> Option { let count = room.unread_notification_counts().notification_count; if count == 0 { return None; } let label = room_label(room); Some(DaemonResponse::error(format!( "refusing to send: {count} unread message(s) in {label}. \ use read_room to view them, then mark_read the latest event before \ sending so you don't talk over messages you haven't seen." ))) } pub async fn send_message(client: &Client, room_ref: &str, body: &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; } let content = RoomMessageEventContent::text_markdown(body); match room.send(content).await { Ok(resp) => DaemonResponse::ok(&serde_json::json!({ "event_id": resp.event_id.to_string(), "room_id": room.room_id().to_string(), })), Err(e) => DaemonResponse::error(format!("send to {}: {e}", room.room_id())), } } 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}")), }, }; if let Some(reject) = unread_guard(&room) { return reject; } let content = RoomMessageEventContent::text_markdown(body); match room.send(content).await { 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(), })), Err(e) => DaemonResponse::error(format!("send DM to {uid}: {e}")), } } pub async fn send_reaction( client: &Client, room_ref: &str, event_id: &str, key: &str, ) -> DaemonResponse { let room = match resolve_room(client, room_ref).await { Ok(r) => r, Err(e) => return e, }; let eid: OwnedEventId = match event_id.parse() { Ok(e) => e, Err(e) => return DaemonResponse::error(format!("invalid event_id {event_id}: {e}")), }; let content = ReactionEventContent::new(Annotation::new(eid.clone(), key.to_owned())); match room.send(content).await { Ok(resp) => DaemonResponse::ok(&serde_json::json!({ "event_id": resp.event_id.to_string(), "target": eid.to_string(), })), Err(e) => DaemonResponse::error(format!("send reaction to {eid}: {e}")), } } pub async fn send_reply( client: &Client, room_ref: &str, event_id: &str, body: &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; } let eid: OwnedEventId = match event_id.parse() { Ok(e) => e, Err(e) => return DaemonResponse::error(format!("invalid event_id {event_id}: {e}")), }; let content = RoomMessageEventContent::text_markdown(body).into(); let reply = Reply { event_id: eid.clone(), enforce_thread: EnforceThread::MaybeThreaded, }; let reply_content = match room.make_reply_event(content, reply).await { Ok(c) => c, Err(e) => return DaemonResponse::error(format!("make_reply_event: {e}")), }; match room.send(reply_content).await { Ok(resp) => DaemonResponse::ok(&serde_json::json!({ "event_id": resp.event_id.to_string(), "target": eid.to_string(), })), Err(e) => DaemonResponse::error(format!("send reply: {e}")), } } pub async fn mark_read(client: &Client, room_ref: &str, event_id: &str) -> DaemonResponse { let room = match resolve_room(client, room_ref).await { Ok(r) => r, Err(e) => return e, }; let eid: OwnedEventId = match event_id.parse() { Ok(e) => e, Err(e) => return DaemonResponse::error(format!("invalid event_id {event_id}: {e}")), }; match room .send_single_receipt(ReceiptType::Read, ReceiptThread::Unthreaded, eid.clone()) .await { Ok(()) => DaemonResponse::ok(&serde_json::json!({ "marked_read": eid.to_string(), })), Err(e) => DaemonResponse::error(format!("send_single_receipt: {e}")), } } pub async fn list_rooms(client: &Client) -> DaemonResponse { let mut rooms = Vec::new(); for room in client.joined_rooms() { let name = match room.display_name().await { Ok(n) => n.to_string(), Err(_) => room.room_id().to_string(), }; let canonical_alias = room.canonical_alias().map(|a| a.to_string()); rooms.push(RoomInfo { room_id: room.room_id().to_string(), canonical_alias, name, member_count: room.joined_members_count(), }); } DaemonResponse::ok(&rooms) } pub async fn list_room_members(client: &Client, room_ref: &str) -> DaemonResponse { let room = match resolve_room(client, room_ref).await { Ok(r) => r, Err(e) => return e, }; let members = match room.members(matrix_sdk::RoomMemberships::JOIN).await { Ok(m) => m, Err(e) => return DaemonResponse::error(format!("members: {e}")), }; let list: Vec = members .iter() .map(|m| MemberInfo { user_id: m.user_id().to_string(), display_name: m.display_name().map(ToOwned::to_owned), }) .collect(); DaemonResponse::ok(&list) } pub fn list_invites(client: &Client) -> DaemonResponse { let invites: Vec = client .invited_rooms() .into_iter() .map(|room| InviteInfo { room_id: room.room_id().to_string(), canonical_alias: room.canonical_alias().map(|a| a.to_string()), name: room.name(), }) .collect(); DaemonResponse::ok(&invites) } /// Rewrite `mcp-loose-ends/matrix.json` with a summary of all pending /// room invites. The harness scans this directory generically in /// `get_loose_ends` — no matrix-specific code needed there. /// /// Called after an invite arrives (from the sync handler) and after a /// room is joined (to remove the accepted invite from loose-ends). /// Atomic write (tmp + rename) so the harness never reads a partial file. pub async fn refresh_invite_loose_ends(client: &Client) { let invites = client.invited_rooms(); let dir = crate::paths::mcp_loose_ends_dir(); if let Err(e) = tokio::fs::create_dir_all(&dir).await { tracing::warn!(error = ?e, "matrix: create mcp-loose-ends dir failed"); return; } let items: Vec = invites .iter() .map(|room| { let label = room_label(room); format!( "[matrix] pending invite: {label} — use list_invites to see, resolve_invite to accept or reject" ) }) .collect(); let dest = dir.join("matrix.json"); let tmp = dest.with_extension("json.tmp"); let json = serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_owned()); match tokio::fs::write(&tmp, &json).await { Ok(()) => { if let Err(e) = tokio::fs::rename(&tmp, &dest).await { tracing::warn!(error = ?e, "matrix: rename mcp-loose-ends/matrix.json failed"); } } Err(e) => { tracing::warn!(error = ?e, "matrix: write mcp-loose-ends/matrix.json.tmp failed"); } } } pub async fn join_room(client: &Client, room_ref: &str) -> DaemonResponse { let parsed: &RoomOrAliasId = match room_ref.try_into() { Ok(p) => p, Err(e) => { return DaemonResponse::error(format!("invalid room reference {room_ref}: {e}")); } }; let server_names: Vec = vec![]; match client.join_room_by_id_or_alias(parsed, &server_names).await { Ok(room) => { // Refresh loose-ends so the accepted invite is removed from // `get_loose_ends` output immediately after the agent joins. refresh_invite_loose_ends(client).await; DaemonResponse::ok(&serde_json::json!({ "joined": true, "room_id": room.room_id().to_string(), })) } Err(e) => DaemonResponse::error(format!("join room {room_ref}: {e}")), } } /// Accept or reject a pending invite to `room_ref` (id or alias). /// /// `Accept` is exactly `join_room` (joining a room you were invited to /// accepts the invite). `Reject` resolves the invited room and leaves /// it, declining the invite. Either way the resolved invite drops out /// of `get_loose_ends` immediately. pub async fn resolve_invite( client: &Client, room_ref: &str, action: InviteAction, ) -> DaemonResponse { match action { InviteAction::Accept => join_room(client, room_ref).await, InviteAction::Reject => { // `resolve_room` -> `get_room` returns the room in any // membership state the store knows, including `Invited`, so // this works for a pending invite that was never joined. let room = match resolve_room(client, room_ref).await { Ok(r) => r, Err(e) => return e, }; match room.leave().await { Ok(()) => { refresh_invite_loose_ends(client).await; DaemonResponse::ok(&serde_json::json!({ "rejected": true, "room_id": room.room_id().to_string(), })) } Err(e) => DaemonResponse::error(format!("reject invite {room_ref}: {e}")), } } } } /// Invite `user_id` to `room_ref`. The calling agent must be a member of /// the room with a power level high enough to invite; otherwise the /// homeserver rejects it and the error is surfaced verbatim. pub async fn invite_user(client: &Client, room_ref: &str, user_id: &str) -> DaemonResponse { let uid: OwnedUserId = match user_id.parse() { Ok(u) => u, Err(e) => return DaemonResponse::error(format!("invalid user_id {user_id}: {e}")), }; let room = match resolve_room(client, room_ref).await { Ok(r) => r, Err(e) => return e, }; match room.invite_user_by_id(&uid).await { Ok(()) => DaemonResponse::ok(&serde_json::json!({ "invited": true, "room_id": room.room_id().to_string(), "user_id": uid.to_string(), })), Err(e) => DaemonResponse::error(format!("invite {uid} to {room_ref}: {e}")), } } pub async fn read_room(client: &Client, room_ref: &str, limit: Option) -> DaemonResponse { use matrix_sdk::room::MessagesOptions; let room = match resolve_room(client, room_ref).await { Ok(r) => r, Err(e) => return e, }; let limit_val = u32::try_from(limit.unwrap_or(50).min(200)).unwrap_or(50); let mut opts = MessagesOptions::backward(); opts.limit = matrix_sdk::ruma::UInt::from(limit_val); // room.messages() transparently decrypts events in encrypted rooms. // UTD (unable to decrypt) events surface via `ev.kind.is_utd()` and // get a sentinel body so claude knows decryption failed rather than // seeing the raw encrypted blob. let msgs = match room.messages(opts).await { Ok(m) => m, Err(e) => return DaemonResponse::error(format!("messages: {e}")), }; let events: Vec = msgs .chunk .iter() .filter_map(|ev| { let parsed = ev.raw().deserialize().ok()?; let event_id = parsed.event_id().to_string(); let sender = parsed.sender().to_string(); let origin_server_ts: i64 = parsed.origin_server_ts().0.into(); let event_type = parsed.event_type().to_string(); let body = if ev.kind.is_utd() { "[unable to decrypt]".to_owned() } else { extract_body_sync(&parsed) }; Some(TimelineEvent { event_id, sender, origin_server_ts, event_type, body, }) }) .collect(); DaemonResponse::ok(&events) } /// Return the number of joined rooms with at least one unread /// notification according to the server-side push notification counts /// cached by the matrix-sdk client. /// /// The harness uses [`unread_summary`] (richer, with per-room body /// snippets) rather than this endpoint. `unread_count` is kept as a /// lightweight public endpoint for callers that only need the count /// and want to avoid the `/messages` network round-trips that /// [`collect_unread`] issues for count==1 rooms. #[must_use] pub fn unread_count(client: &Client) -> DaemonResponse { let rooms = u32::try_from( client .joined_rooms() .into_iter() .filter(|r| r.unread_notification_counts().notification_count > 0) .count(), ) .unwrap_or(u32::MAX); DaemonResponse::ok(&serde_json::json!({ "rooms": rooms })) } /// Collect all rooms with unread notifications and build per-room /// [`crate::protocol::RoomUnread`] entries. For rooms with exactly /// one unread notification the last message body is fetched via the /// `/messages` endpoint (best-effort; failures leave `last_body` as /// `None`). Rooms with zero unreads are omitted. /// /// **Latency note**: each count==1 room triggers a live `/messages` /// network request to the matrix homeserver to retrieve the message /// body. This adds per-room round-trip latency to `get_loose_ends` /// and the wake-signal path. Acceptable in practice (rooms with /// unread are few; request is best-effort), but worth bearing in /// mind if latency becomes a concern. #[must_use] pub async fn collect_unread(client: &Client) -> Vec { use crate::protocol::RoomUnread; let mut result = Vec::new(); for room in client.joined_rooms() { let count = u32::try_from(room.unread_notification_counts().notification_count).unwrap_or(u32::MAX); if count == 0 { continue; } let label = room_label(&room); let (last_body, last_sender) = if count == 1 { fetch_last_message(client, &room).await } else { (None, None) }; result.push(RoomUnread { label, count, last_body, last_sender, }); } result } /// Fetch the body + sender of the most recent room message. Returns /// `(None, None)` on any error or when the timeline contains no text /// events. async fn fetch_last_message( client: &Client, room: &matrix_sdk::Room, ) -> (Option, Option) { use matrix_sdk::ruma::api::Direction; use matrix_sdk::ruma::api::client::message::get_message_events; let mut req = get_message_events::v3::Request::new(room.room_id().to_owned(), Direction::Backward); req.limit = matrix_sdk::ruma::UInt::from(1u32); let Ok(resp) = client.send(req).await else { return (None, None); }; for raw in &resp.chunk { if let Ok(ev) = raw.deserialize() { let body = extract_body(&ev); if !body.is_empty() { return ( Some(crate::wake::truncate_chars(&body, 100)), Some(ev.sender().to_string()), ); } } } (None, None) } /// Return per-room unread summaries. For rooms with exactly one /// unread notification, attempts to include the sender + truncated /// body; rooms with multiple unreads carry only the count. #[must_use] pub async fn unread_summary(client: &Client) -> DaemonResponse { let rooms = collect_unread(client).await; DaemonResponse::ok(&rooms) }