send reactions via '=== react emoji'; reactions also trigger shard

This commit is contained in:
Damocles 2026-04-30 22:39:40 +02:00
parent bf29e9f7d9
commit c8eb9870a9

View file

@ -15,7 +15,9 @@ use matrix_sdk::{
filter::FilterDefinition, receipt::create_receipt::v3::ReceiptType as CreateReceiptType, filter::FilterDefinition, receipt::create_receipt::v3::ReceiptType as CreateReceiptType,
}, },
events::{ events::{
reaction::ReactionEventContent,
receipt::ReceiptThread, receipt::ReceiptThread,
relation::Annotation,
room::{ room::{
member::StrippedRoomMemberEvent, member::StrippedRoomMemberEvent,
message::{MessageType, OriginalSyncRoomMessageEvent, RoomMessageEventContent}, message::{MessageType, OriginalSyncRoomMessageEvent, RoomMessageEventContent},
@ -267,13 +269,24 @@ async fn sync(
tracing::info!("synced, listening for messages"); tracing::info!("synced, listening for messages");
let msg_state = state.clone();
client.add_event_handler(move |event: OriginalSyncRoomMessageEvent, room: Room| { client.add_event_handler(move |event: OriginalSyncRoomMessageEvent, room: Room| {
let state = state.clone(); let state = msg_state.clone();
async move { async move {
on_room_message(event, room, state).await; 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.add_event_handler(on_stripped_state_member);
client.sync(sync_settings).await?; client.sync(sync_settings).await?;
@ -281,6 +294,32 @@ async fn sync(
bail!("sync loop exited unexpectedly") bail!("sync loop exited unexpectedly")
} }
async fn on_reaction(
event: matrix_sdk::ruma::events::reaction::OriginalSyncReactionEvent,
room: Room,
state: Arc<Mutex<DaemonState>>,
) {
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) { async fn on_stripped_state_member(event: StrippedRoomMemberEvent, client: Client, room: Room) {
let Some(my_id) = client.user_id() else { let Some(my_id) = client.user_id() else {
return; return;
@ -651,6 +690,18 @@ async fn process_loop(state: Arc<Mutex<DaemonState>>, client: Client) {
tracing::warn!(target = %target_label, "target not available"); 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<OwnedEventId> {
// 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 { fn ts_secs_from(ts: matrix_sdk::ruma::UInt) -> i64 {
let ms: u64 = ts.into(); let ms: u64 = ts.into();
i64::try_from(ms).unwrap_or(0) / 1000 i64::try_from(ms).unwrap_or(0) / 1000
@ -881,6 +951,9 @@ enum ClaudeDoc {
target: ResponseTarget, target: ResponseTarget,
body: String, 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. /// Agent's internal monologue. Not sent to chat. Logged to tracing.
Thought(String), Thought(String),
/// Explicit "do nothing for this slot". Useful as a placeholder. /// 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<Cla
Err(_) => None, Err(_) => None,
} }
} }
"react" => {
// Allow either `=== react <eid> <key>` (key in header) or
// `=== react <eid>` 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 // Unknown header - treat body as a thought so it doesn't leak to chat
if body.is_empty() { if body.is_empty() {
@ -1249,6 +1343,31 @@ private
assert!(matches!(docs[1], ClaudeDoc::Message { .. })); 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] #[test]
fn parse_unknown_header_becomes_thought() { fn parse_unknown_header_becomes_thought() {
let raw = "=== mystery foo\nbody"; let raw = "=== mystery foo\nbody";