//! Matrix event handlers: incoming room messages fire a hyperhive //! wake signal so the agent's harness drives a new claude turn. //! //! Per mara on #548: wake body is a SHORT TEASER, not the full message //! (msg stays unread server-side; agent fetches via `read_room`). The //! `wake::format_wake_body` truncates to ~100 chars. //! //! Self-events (events sent by this agent) are filtered out so an //! agent posting a message doesn't wake itself. use std::path::PathBuf; use std::sync::Arc; use matrix_sdk::{ Client, Room, RoomState, ruma::events::room::member::{MembershipState, StrippedRoomMemberEvent}, ruma::events::room::message::{MessageType, OriginalSyncRoomMessageEvent}, }; use crate::{handlers, wake}; /// Install the room-message handler on `client`. Fires on every /// `m.room.message` event in a joined room; non-self messages trigger /// a wake signal to the hyperhive harness via the unix socket at /// `hyperhive_socket`. The wake body summarises ALL rooms with unread /// notifications at wake time (not just the triggering event) so the /// agent receives a full picture in one prompt. pub fn install_message_handler(client: &Client, hyperhive_socket: PathBuf) { let socket = Arc::new(hyperhive_socket); let own_user = client.user_id().map(std::borrow::ToOwned::to_owned); client.add_event_handler({ let socket = socket.clone(); move |event: OriginalSyncRoomMessageEvent, room: Room, client: Client| { let socket = socket.clone(); let own_user = own_user.clone(); async move { if room.state() != RoomState::Joined { return; } // Self-event filter so the agent's own outgoing messages // don't wake it. matches forge_notify's self-skip pattern. if own_user.as_ref().is_some_and(|u| u == &event.sender) { return; } // Build a wake body that covers all rooms with unread // notifications, not just the triggering event. This lets // the agent see the full backlog in a single wake prompt. // Falls back to the per-event teaser if the collect fails // (empty result means daemon is not seeing any unread yet — // unlikely but possible during a sync race). let unread = handlers::collect_unread(&client).await; let body = if unread.is_empty() { // Sync hasn't updated notification counts yet; fall back // to the current event so the agent still wakes. let text = match &event.content.msgtype { MessageType::Text(t) => t.body.clone(), MessageType::Notice(n) => n.body.clone(), MessageType::Emote(e) => format!("* {}", e.body), _ => format!("[{}]", event.content.msgtype()), }; let room_label = room .canonical_alias() .map_or_else(|| room.room_id().to_string(), |a| a.to_string()); wake::format_wake_body(event.sender.as_str(), &room_label, &text) } else { wake::format_unread_summary(&unread) }; if let Err(e) = wake::send_wake(&socket, &body).await { tracing::warn!(error = %e, "failed to deliver matrix wake to hyperhive"); } else { tracing::debug!(room = %room.room_id(), "matrix wake delivered"); } } } }); tracing::info!("matrix message handler installed"); } /// Install a handler that wakes the agent when a room invite arrives. /// The agent decides whether to accept by calling `join_room`. /// /// When the daemon receives an `m.room.member` state event with /// `membership: invite` for the agent's own user ID during sync, it /// fires a hyperhive wake so the agent can inspect the invite via /// `list_invites` and act on it with `join_room`. /// /// This closes the gap where invites accumulated in `invited_rooms()` /// forever without the agent being notified — the daemon's message /// handler only fires for joined rooms. pub fn install_invite_handler(client: &Client, hyperhive_socket: PathBuf) { let socket = Arc::new(hyperhive_socket); let own_user = client.user_id().map(std::borrow::ToOwned::to_owned); client.add_event_handler({ move |event: StrippedRoomMemberEvent, room: Room, client: Client| { let socket = socket.clone(); let own_user = own_user.clone(); async move { // Only handle invite events for our own user. if event.content.membership != MembershipState::Invite { return; } let is_ours = own_user .as_ref() .is_some_and(|u| u.as_str() == event.state_key.as_str()); if !is_ours { return; } let room_id = room.room_id().to_owned(); let label = room.name().unwrap_or_else(|| room_id.to_string()); tracing::info!(%room_id, "matrix: received room invite, waking agent"); // Refresh loose-ends BEFORE waking so the invite is // visible in get_loose_ends during the agent's turn. handlers::refresh_invite_loose_ends(&client).await; let body = format!( "[matrix] invited to {label} ({room_id}) — \ use list_invites to see pending invites, join_room to accept" ); if let Err(e) = wake::send_wake(&socket, &body).await { tracing::warn!( error = %e, "matrix: failed to deliver invite-wake to hyperhive" ); } } } }); tracing::info!("matrix invite handler installed"); }