send reactions via '=== react emoji'; reactions also trigger shard
This commit is contained in:
parent
bf29e9f7d9
commit
c8eb9870a9
1 changed files with 120 additions and 1 deletions
121
src/main.rs
121
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<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) {
|
||||
let Some(my_id) = client.user_id() else {
|
||||
return;
|
||||
|
|
@ -651,6 +690,18 @@ async fn process_loop(state: Arc<Mutex<DaemonState>>, 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<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 {
|
||||
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<Cla
|
|||
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
|
||||
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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue