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,
|
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";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue