Files
sol/src/matrix_utils.rs
Sienna Meridian Satterwhite 7324c10d25 proper Matrix threading + concise tool call formatting
agent_ux: uses Relation::Thread (not Reply) for tool call details.
format_tool_call extracts key params instead of dumping raw JSON.
format_tool_result truncated to 200 chars.

matrix_utils: added make_thread_reply() for threaded responses.
sync.rs routes ThreadReply engagement to threaded messages.
2026-03-23 01:42:08 +00:00

160 lines
5.5 KiB
Rust

use matrix_sdk::media::{MediaFormat, MediaRequestParameters};
use matrix_sdk::room::Room;
use matrix_sdk::RoomMemberships;
use ruma::events::room::message::{
MessageType, OriginalSyncRoomMessageEvent, Relation, RoomMessageEventContent,
};
use ruma::events::relation::{Annotation, InReplyTo};
use ruma::events::reaction::ReactionEventContent;
use ruma::OwnedEventId;
/// Extract the plain-text body from a message event.
pub fn extract_body(event: &OriginalSyncRoomMessageEvent) -> Option<String> {
match &event.content.msgtype {
MessageType::Text(text) => Some(text.body.clone()),
MessageType::Notice(notice) => Some(notice.body.clone()),
MessageType::Emote(emote) => Some(emote.body.clone()),
_ => None,
}
}
/// Check if this event is an edit (m.replace relation) and return the new body.
pub fn extract_edit(event: &OriginalSyncRoomMessageEvent) -> Option<(OwnedEventId, String)> {
if let Some(Relation::Replacement(replacement)) = &event.content.relates_to {
let new_body = match &replacement.new_content.msgtype {
MessageType::Text(text) => text.body.clone(),
MessageType::Notice(notice) => notice.body.clone(),
_ => return None,
};
return Some((replacement.event_id.clone(), new_body));
}
None
}
/// Extract the event ID being replied to, if any.
pub fn extract_reply_to(event: &OriginalSyncRoomMessageEvent) -> Option<OwnedEventId> {
if let Some(Relation::Reply { in_reply_to }) = &event.content.relates_to {
return Some(in_reply_to.event_id.clone());
}
None
}
/// Extract thread root event ID, if any.
pub fn extract_thread_id(event: &OriginalSyncRoomMessageEvent) -> Option<OwnedEventId> {
if let Some(Relation::Thread(thread)) = &event.content.relates_to {
return Some(thread.event_id.clone());
}
None
}
/// Build a reply message content with m.in_reply_to relation and markdown rendering.
pub fn make_reply_content(body: &str, reply_to_event_id: OwnedEventId) -> RoomMessageEventContent {
let mut content = RoomMessageEventContent::text_markdown(body);
content.relates_to = Some(Relation::Reply {
in_reply_to: InReplyTo::new(reply_to_event_id),
});
content
}
/// Build a threaded reply — shows up in Matrix threads UI.
/// The thread root is the event being replied to (creates the thread on first use).
pub fn make_thread_reply(
body: &str,
thread_root_id: OwnedEventId,
) -> RoomMessageEventContent {
use ruma::events::relation::Thread;
let mut content = RoomMessageEventContent::text_markdown(body);
let thread = Thread::plain(thread_root_id.clone(), thread_root_id);
content.relates_to = Some(Relation::Thread(thread));
content
}
/// Send an emoji reaction to a message.
pub async fn send_reaction(
room: &Room,
event_id: OwnedEventId,
emoji: &str,
) -> anyhow::Result<()> {
let annotation = Annotation::new(event_id, emoji.to_string());
let content = ReactionEventContent::new(annotation);
room.send(content).await?;
Ok(())
}
/// Extract image info from an m.image message event.
/// Returns (mxc_url, mimetype, body/caption) if present.
pub fn extract_image(event: &OriginalSyncRoomMessageEvent) -> Option<(String, String, String)> {
if let MessageType::Image(image) = &event.content.msgtype {
let url = match &image.source {
ruma::events::room::MediaSource::Plain(mxc) => mxc.to_string(),
ruma::events::room::MediaSource::Encrypted(_) => return None,
};
let mime = image
.info
.as_ref()
.and_then(|i| i.mimetype.clone())
.unwrap_or_else(|| "image/png".to_string());
let caption = image.body.clone();
Some((url, mime, caption))
} else {
None
}
}
/// Download image bytes from a Matrix mxc:// URL via the media API.
/// Returns the raw bytes as a base64 data URI suitable for Mistral vision.
pub async fn download_image_as_data_uri(
client: &matrix_sdk::Client,
event: &OriginalSyncRoomMessageEvent,
) -> Option<String> {
if let MessageType::Image(image) = &event.content.msgtype {
let media_source = &image.source;
let mime = image
.info
.as_ref()
.and_then(|i| i.mimetype.clone())
.unwrap_or_else(|| "image/png".to_string());
let request = MediaRequestParameters {
source: media_source.clone(),
format: MediaFormat::File,
};
match client.media().get_media_content(&request, true).await {
Ok(bytes) => {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
Some(format!("data:{};base64,{}", mime, b64))
}
Err(e) => {
tracing::warn!("Failed to download image: {e}");
None
}
}
} else {
None
}
}
/// Get the display name for a room.
pub fn room_display_name(room: &Room) -> String {
room.cached_display_name()
.map(|n| n.to_string())
.unwrap_or_else(|| room.room_id().to_string())
}
/// Get member display names for a room.
pub async fn room_member_names(room: &Room) -> Vec<String> {
match room.members(RoomMemberships::JOIN).await {
Ok(members) => members
.iter()
.map(|m| {
m.display_name()
.unwrap_or_else(|| m.user_id().as_str())
.to_string()
})
.collect(),
Err(_) => Vec::new(),
}
}