From c8eb9870a95a5698fec43937481fef2a4d8195cf Mon Sep 17 00:00:00 2001 From: Damocles Date: Thu, 30 Apr 2026 22:39:40 +0200 Subject: [PATCH] send reactions via '=== react emoji'; reactions also trigger shard --- src/main.rs | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 94f3d3c..9f66700 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,9 @@ use matrix_sdk::{ filter::FilterDefinition, receipt::create_receipt::v3::ReceiptType as CreateReceiptType, }, events::{ + reaction::ReactionEventContent, receipt::ReceiptThread, + relation::Annotation, room::{ member::StrippedRoomMemberEvent, message::{MessageType, OriginalSyncRoomMessageEvent, RoomMessageEventContent}, @@ -267,13 +269,24 @@ async fn sync( tracing::info!("synced, listening for messages"); + let msg_state = state.clone(); client.add_event_handler(move |event: OriginalSyncRoomMessageEvent, room: Room| { - let state = state.clone(); + let state = msg_state.clone(); async move { on_room_message(event, room, state).await; } }); + let react_state = state.clone(); + client.add_event_handler( + move |event: matrix_sdk::ruma::events::reaction::OriginalSyncReactionEvent, room: Room| { + let state = react_state.clone(); + async move { + on_reaction(event, room, state).await; + } + }, + ); + client.add_event_handler(on_stripped_state_member); client.sync(sync_settings).await?; @@ -281,6 +294,32 @@ async fn sync( bail!("sync loop exited unexpectedly") } +async fn on_reaction( + event: matrix_sdk::ruma::events::reaction::OriginalSyncReactionEvent, + room: Room, + state: Arc>, +) { + if room.state() != RoomState::Joined { + return; + } + let room_id = room.room_id().to_owned(); + let mut state = state.lock().await; + let is_self = event.sender == state.own_user_id; + + tracing::info!( + room = %room_id, + sender = %event.sender, + self_react = is_self, + target = %event.content.relates_to.event_id, + key = %event.content.relates_to.key, + "reaction" + ); + + if !is_self && !state.pending_rooms.contains(&room_id) { + state.pending_rooms.push(room_id); + } +} + async fn on_stripped_state_member(event: StrippedRoomMemberEvent, client: Client, room: Room) { let Some(my_id) = client.user_id() else { return; @@ -651,6 +690,18 @@ async fn process_loop(state: Arc>, client: Client) { tracing::warn!(target = %target_label, "target not available"); } } + ClaudeDoc::Reaction { target_id_arg, key } => { + let Some(full_eid) = resolve_event_id(&timeline, &target_id_arg) else { + tracing::warn!(arg = %target_id_arg, "react: target event id not found in timeline"); + continue; + }; + let content = + ReactionEventContent::new(Annotation::new(full_eid.clone(), key.clone())); + match room.send(content).await { + Ok(_) => tracing::info!(target = %full_eid, %key, "sent reaction"), + Err(e) => tracing::error!("failed to send reaction: {e}"), + } + } } } @@ -830,6 +881,25 @@ fn short_event_id(id: &OwnedEventId) -> String { } } +/// Resolve a (possibly shortened/ellipsized) event id to a full one by +/// looking up against the timeline. Returns the matching message's full +/// event id if found. +fn resolve_event_id(timeline: &[TimelineItem], arg: &str) -> Option { + // Strip a trailing ellipsis or `...` if present + let cleaned = arg.trim_end_matches('…').trim_end_matches('.').trim(); + if cleaned.is_empty() { + return None; + } + for item in timeline { + if let TimelineItem::Message { event_id, .. } = item { + if event_id.as_str() == cleaned || event_id.as_str().starts_with(cleaned) { + return Some(event_id.clone()); + } + } + } + None +} + fn ts_secs_from(ts: matrix_sdk::ruma::UInt) -> i64 { let ms: u64 = ts.into(); i64::try_from(ms).unwrap_or(0) / 1000 @@ -881,6 +951,9 @@ enum ClaudeDoc { target: ResponseTarget, body: String, }, + /// A reaction to a message. `target_id_arg` is the event id (possibly + /// shortened) the agent saw in the prompt; daemon expands by prefix match. + Reaction { target_id_arg: String, key: String }, /// Agent's internal monologue. Not sent to chat. Logged to tracing. Thought(String), /// Explicit "do nothing for this slot". Useful as a placeholder. @@ -1106,6 +1179,27 @@ fn build_doc(header: &str, body: &str, default_room: &OwnedRoomId) -> Option None, } } + "react" => { + // Allow either `=== react ` (key in header) or + // `=== react ` with body=key. + let mut header_parts = arg.splitn(2, char::is_whitespace); + let eid_arg = header_parts.next().unwrap_or("").trim(); + let key_in_header = header_parts.next().unwrap_or("").trim(); + if eid_arg.is_empty() { + return None; + } + let key = if !key_in_header.is_empty() { + key_in_header.to_owned() + } else if !body.is_empty() { + body.to_owned() + } else { + return None; + }; + Some(ClaudeDoc::Reaction { + target_id_arg: eid_arg.to_owned(), + key, + }) + } _ => { // Unknown header - treat body as a thought so it doesn't leak to chat if body.is_empty() { @@ -1249,6 +1343,31 @@ private assert!(matches!(docs[1], ClaudeDoc::Message { .. })); } + #[test] + fn parse_react_with_key_in_header() { + let raw = "=== react $abc12345… šŸ‘€"; + let docs = parse_response(raw, &test_room()); + assert_eq!(docs.len(), 1); + match &docs[0] { + ClaudeDoc::Reaction { target_id_arg, key } => { + assert_eq!(target_id_arg, "$abc12345…"); + assert_eq!(key, "šŸ‘€"); + } + _ => panic!("expected reaction"), + } + } + + #[test] + fn parse_react_with_key_in_body() { + let raw = "=== react $abc12345…\nšŸ”„"; + let docs = parse_response(raw, &test_room()); + assert_eq!(docs.len(), 1); + match &docs[0] { + ClaudeDoc::Reaction { key, .. } => assert_eq!(key, "šŸ”„"), + _ => panic!("expected reaction"), + } + } + #[test] fn parse_unknown_header_becomes_thought() { let raw = "=== mystery foo\nbody";