Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-matrix-mcp/src/handlers.rs
damocles b89ca3955a hive-matrix: the unread sweep after a sync looks at the rooms the sync touched, not at every joined room
Each room the sweep looks at costs a live /messages fetch of up to 50 events, and the sweep ran for every joined room after every sync response, so a busy account fetched thousands of times an hour whatever the sync itself carried. The sweep now covers the rooms the response names (sliding sync's UpdateSummary rooms, classic's joined rooms), with a full pass first and every five minutes.
2026-11-11 17:05:57 +01:00

1888 lines
71 KiB
Rust

//! Per-tool dispatch — each MCP tool call in [`crate::mcp`] resolves to
//! one of these handlers. Returns a [`DaemonResponse`] which
//! `crate::mcp::render` turns into the tool-result string claude sees
//! (`Ok { payload }` → pretty JSON, `Error { message }` → a "matrix
//! error: …" prefixed string).
//!
//! Tool surface mirrors damocles-daemon's v0 set per the operator's call:
//! `send_message`, `send_dm`, `send_reaction`, `send_reply`, `mark_read`,
//! `send_redact`, `list_rooms`, `list_room_members`, `read_room`.
use matrix_sdk::{
Client, RoomMemberships,
room::reply::{EnforceThread, Reply},
ruma::{
OwnedEventId, OwnedRoomId, OwnedServerName, OwnedUserId, RoomOrAliasId,
api::client::receipt::create_receipt::v3::ReceiptType,
events::{
reaction::ReactionEventContent,
receipt::{ReceiptThread, ReceiptType as LocalReceiptType},
relation::Annotation,
room::member::MembershipState,
room::message::{AddMentions, MessageType, RoomMessageEventContent},
},
},
};
use serde::Serialize;
use crate::protocol::{DaemonResponse, InviteAction};
/// JSON-shape for `list_invites`: one row per pending room invite.
#[derive(Debug, Serialize)]
pub struct InviteInfo {
pub room_id: String,
pub canonical_alias: Option<String>,
pub name: Option<String>,
/// Who sent the invite — the `sender` of the `m.room.member` invite
/// event for this account. `None` when the invite's member event isn't
/// in the local store yet (`invite_details` errors in that case), not
/// just on an outright lookup failure.
pub inviter: Option<String>,
/// The room's topic, where set. Most invites to a freshly created room
/// won't have one; `canonical_alias`/`name` are just as commonly unset,
/// so this is one more thing to judge the invite by, not a guarantee.
pub topic: Option<String>,
}
/// JSON-shape for `list_rooms`: one row per joined room.
#[derive(Debug, Serialize)]
pub struct RoomInfo {
pub room_id: String,
pub canonical_alias: Option<String>,
pub name: String,
pub member_count: u64,
}
/// JSON-shape for `list_room_members`: one row per joined member.
#[derive(Debug, Serialize)]
pub struct MemberInfo {
pub user_id: String,
pub display_name: Option<String>,
}
/// JSON-shape for `read_room`: one timeline event, flattened for
/// claude consumption.
#[derive(Debug, Serialize)]
pub struct TimelineEvent {
pub event_id: String,
pub sender: String,
pub origin_server_ts: i64,
pub event_type: String,
/// Best-effort plain-text body for `m.room.message` events
/// (text / notice / emote); empty for events without a textual
/// body (state changes, reactions, etc.).
pub body: String,
/// Event id of the message this replies to (`m.relates_to` →
/// `m.in_reply_to`), including the reply fallback a thread message
/// carries. `None` for top-level (non-reply) messages.
#[serde(skip_serializing_if = "Option::is_none")]
pub in_reply_to_event_id: Option<String>,
/// Set on a message that was edited: `body` then holds the latest
/// edited text, not the text first posted.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub edited: bool,
/// Set on an edit (`m.replace`) event whose original is outside the
/// window read: the id of the message it replaces, with `body` holding
/// the new text. Edits whose original is in the window are folded into
/// it by `collapse_edits` and never appear on their own.
#[serde(skip_serializing_if = "Option::is_none")]
pub replaces_event_id: Option<String>,
}
/// The `(replaced event id, new body)` an edit (`m.replace`) event
/// carries, or `None` for any other event. The top-level `body` of an edit
/// is a `* `-prefixed fallback for clients that cannot render edits; the
/// text to show is in `m.new_content`.
fn replacement_of(
event: &matrix_sdk::ruma::events::AnySyncTimelineEvent,
) -> Option<(String, String)> {
use matrix_sdk::ruma::events::room::message::Relation;
use matrix_sdk::ruma::events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent};
let AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(ev)) = event else {
return None;
};
match ev.as_original()?.content.relates_to.as_ref()? {
Relation::Replacement(r) => Some((
r.event_id.to_string(),
attachment_marker(&r.new_content.msgtype),
)),
_ => None,
}
}
/// Fold each edit into the message it replaces, so a reader sees the
/// edited text once instead of the original plus a `* ` duplicate.
/// `events` is newest first, so the first edit met for a message is its
/// latest. An edit only counts when its sender also sent the original
/// (the spec's rule); one whose original is outside the window stays as an
/// event of its own with `replaces_event_id` set.
fn collapse_edits(events: Vec<TimelineEvent>) -> Vec<TimelineEvent> {
let mut latest: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut folded: std::collections::HashSet<String> = std::collections::HashSet::new();
for edit in &events {
let Some(target) = &edit.replaces_event_id else {
continue;
};
let original_by_same_sender = events
.iter()
.any(|e| &e.event_id == target && e.sender == edit.sender);
if original_by_same_sender {
folded.insert(edit.event_id.clone());
latest
.entry(target.clone())
.or_insert_with(|| edit.body.clone());
}
}
events
.into_iter()
.filter(|e| !folded.contains(&e.event_id))
.map(|mut e| {
if let Some(body) = latest.remove(&e.event_id) {
e.body = body;
e.edited = true;
}
e
})
.collect()
}
/// Resolve a room reference (id `!abc:server` OR alias `#name:server`)
/// to a joined `Room`. Returns an `Error` response if the room isn't
/// joined / the reference is malformed.
async fn resolve_room(
client: &Client,
reference: &str,
) -> Result<matrix_sdk::Room, DaemonResponse> {
let parsed: &RoomOrAliasId = reference
.try_into()
.map_err(|e| DaemonResponse::error(format!("invalid room reference {reference}: {e}")))?;
let room_id: OwnedRoomId = if parsed.is_room_id() {
OwnedRoomId::try_from(reference)
.map_err(|e| DaemonResponse::error(format!("invalid room_id: {e}")))?
} else {
client
.resolve_room_alias(
parsed
.as_str()
.try_into()
.map_err(|e| DaemonResponse::error(format!("invalid alias: {e}")))?,
)
.await
.map(|r| r.room_id)
.map_err(|e| DaemonResponse::error(format!("resolve_room_alias {reference}: {e}")))?
};
client
.get_room(&room_id)
.ok_or_else(|| DaemonResponse::error(format!("room {room_id} not joined")))
}
/// Best-effort plain-text body / attachment marker for a message
/// `MessageType`. Text-like messages return their body; media messages
/// (`m.file` / `m.image` / `m.audio` / `m.video`) return a
/// `[file: name]`-style marker so an agent reading the room sees that an
/// attachment is present (and can fetch it with `download_file`); all
/// other types return "".
fn attachment_marker(msgtype: &MessageType) -> String {
match msgtype {
MessageType::Text(t) => t.body.clone(),
MessageType::Notice(n) => n.body.clone(),
MessageType::Emote(e) => format!("* {}", e.body),
MessageType::File(f) => format!("[file: {}]", f.body),
MessageType::Image(i) => format!("[image: {}]", i.body),
MessageType::Audio(a) => format!("[audio: {}]", a.body),
MessageType::Video(v) => format!("[video: {}]", v.body),
_ => String::new(),
}
}
/// Best-effort plain-text body for the `AnySyncTimelineEvent` variant
/// returned by `TimelineEvent::raw()` (the sdk-level decrypted form from
/// `room.messages()`). Returns "" for non-text events (state changes,
/// reactions, redactions) — claude can still see the `event_type` field
/// to disambiguate. The one body-extractor in this module: everything
/// reading timeline events goes through `room.messages()` now, not a raw
/// `/messages` call, so there's no separate non-sync form to extract from.
fn extract_body_sync(event: &matrix_sdk::ruma::events::AnySyncTimelineEvent) -> String {
use matrix_sdk::ruma::events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent};
match event {
AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(ev)) => ev
.as_original()
.map_or_else(String::new, |orig| attachment_marker(&orig.content.msgtype)),
_ => String::new(),
}
}
/// Extract the event id this message replies to, from its `m.relates_to`.
/// Handles both a direct `m.in_reply_to` reply and the reply fallback a
/// thread message carries. Returns `None` for non-reply messages and for
/// non-`m.room.message` events.
fn extract_in_reply_to(event: &matrix_sdk::ruma::events::AnySyncTimelineEvent) -> Option<String> {
use matrix_sdk::ruma::events::room::message::Relation;
use matrix_sdk::ruma::events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent};
let AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(ev)) = event else {
return None;
};
match ev.as_original()?.content.relates_to.as_ref()? {
Relation::Reply(reply) => Some(reply.in_reply_to.event_id.to_string()),
Relation::Thread(thread) => thread.in_reply_to.as_ref().map(|r| r.event_id.to_string()),
_ => None,
}
}
/// Human-facing label for a room: its canonical alias when set,
/// otherwise the raw room id. Used wherever a room is named in
/// agent-facing text (wake bodies, loose-ends, the unread guard).
#[must_use]
pub fn room_label(room: &matrix_sdk::Room) -> String {
room.canonical_alias()
.map_or_else(|| room.room_id().to_string(), |a| a.to_string())
}
/// Refuse to post into a room the agent hasn't caught up on. Returns
/// `Some(error)` with a helpful hint when the room still has unread
/// content (the agent must `read_room` then `mark_read` the latest event
/// first), or `None` when the send may proceed.
///
/// Read-state is `room_unread_state` — the same predicate the wake path
/// and `get_loose_ends` use, so "caught up" here means exactly what those
/// surfaces mean. Reactions and `mark_read` are not gated — only
/// message-posting tools (`send_message`, `send_reply`, `send_dm`) so an
/// agent can't talk over messages it hasn't seen.
async fn unread_guard(client: &Client, room: &matrix_sdk::Room) -> Option<DaemonResponse> {
room_unread_state(client, room).await?;
let label = room_label(room);
Some(DaemonResponse::error(format!(
"refusing to send: unread message(s) in {label}. \
use read_room to view them, then mark_read the latest event before \
sending so you don't talk over messages you haven't seen."
)))
}
/// Timeline events to scan backward, per room, when hunting for the
/// latest event that carries actor intent (see [`is_intentional`])
/// before giving up and answering conservatively. Bounds the walk a
/// chatty periodic state re-emit (the prior `m.space.child` PUT-always-
/// appends-an-event fix, since resolved on the forge tracker: `m.space.child`
/// PUT'd unconditionally on an unchanged value, still appending a timeline
/// event) would otherwise force — see [`room_unread_state`] for how the
/// bound is used and why it doesn't need to be exact.
const UNREAD_LOOKBACK: u32 = 50;
/// Fetch up to [`UNREAD_LOOKBACK`] timeline events in `room`, newest
/// first, of any type (redactions/state/reactions included —
/// `room_unread_state` filters by type itself). Empty for a genuinely
/// empty room or on any request failure.
///
/// `room.messages()`, not a raw `/messages` call: the SDK method
/// transparently decrypts in an encrypted room (same path `read_room`
/// uses), where a raw call returns undecryptable `m.room.encrypted`
/// ciphertext. That mattered in practice — every unread event in an
/// encrypted room produced the identical empty body, so
/// `format_unread_summary` never changed text between two genuinely
/// different unread messages and the harness's content-diff upsert saw
/// no change to wake on.
async fn recent_events(
room: &matrix_sdk::Room,
) -> Vec<matrix_sdk::deserialized_responses::TimelineEvent> {
use matrix_sdk::room::MessagesOptions;
let mut opts = MessagesOptions::backward();
opts.limit = matrix_sdk::ruma::UInt::from(UNREAD_LOOKBACK);
let Ok(resp) = room.messages(opts).await else {
return Vec::new();
};
resp.chunk
}
/// Whether `event` carries actor intent, as opposed to noise nobody
/// "did" anything to produce. Everything counts by default — messages
/// (of every msgtype, not just `m.room.message`'s common ones),
/// reactions, state changes (join/leave/topic/name/etc. are all real
/// activity an agent should see, mara: don't lump them in with
/// housekeeping noise). The deliberate exceptions are `m.space.child`:
/// a periodic re-apply of an *unchanged* value still appends a timeline
/// event (see [`room_unread_state`]'s doc comment), and that specific
/// redundant re-emit is the thing this function exists to filter —
/// an earlier version of this filter over-corrected by excluding whole
/// event categories instead of just that one type; narrower is right —
/// and an edit (`m.replace`), which restates a message already counted.
fn is_intentional(event: &matrix_sdk::ruma::events::AnySyncTimelineEvent) -> bool {
use matrix_sdk::ruma::events::{AnySyncStateEvent, AnySyncTimelineEvent};
// An edit (`m.replace`) is the same message again, not a new one: it
// must not count as a second unread (which would also hold up sends).
!matches!(
event,
AnySyncTimelineEvent::State(AnySyncStateEvent::SpaceChild(_))
) && replacement_of(event).is_none()
}
/// Whether `room` carries content the agent hasn't caught up on.
/// `Some((event_id, sender, body))` for the latest event when it's
/// genuinely unread; `None` when the room is caught up, empty, or its
/// latest event is self-authored.
///
/// Diffs the room's own local state against the agent's own `m.read`
/// receipt instead of `unread_notification_counts()`. That count is
/// server-computed from Matrix **push rules** — a DM or a plain message
/// that doesn't match any push rule (no keyword, no @mention) can leave a
/// genuinely-unread event reporting `notification_count: 0` forever, so
/// the agent never wakes for it (confirmed live on a prior investigation:
/// the daemon had the event in its local store the whole time and still
/// reported zero — the count was never the right signal to read).
/// Comparing the latest local event id against the own-account read
/// receipt needs no push-rule semantics at all.
///
/// Self-authored latest events are never "unread" — after a daemon
/// rebuild the read receipt can lag behind the agent's own just-sent
/// message, which must not self-wake it.
///
/// Walks backward through up to [`UNREAD_LOOKBACK`] events (not just the
/// newest one) looking for the first [`is_intentional`] hit, skipping a
/// chatty state re-emit nobody "posted" instead of treating it as gospel.
/// See the three branches below for what each outcome means.
async fn room_unread_state(
client: &Client,
room: &matrix_sdk::Room,
) -> Option<(OwnedEventId, OwnedUserId, String)> {
let own_user_id = client.user_id()?;
// `load_user_receipt` takes `ruma::events::receipt::ReceiptType`
// (imported here as `LocalReceiptType`), a distinct type from the
// `ReceiptType` this module already imports for `send_single_receipt`
// (`ruma::api::client::receipt::create_receipt::v3::ReceiptType`) — two
// same-named enums from different ruma crates, not interchangeable.
let read_event_id = room
.load_user_receipt(
LocalReceiptType::Read,
ReceiptThread::Unthreaded,
own_user_id,
)
.await
.ok()
.flatten()
.map(|(id, _)| id);
let is_encrypted = room.encryption_state().is_encrypted();
let events: Vec<ParsedEvent> = recent_events(room)
.await
.iter()
.map(|raw| {
raw.raw()
.deserialize()
.ok()
.map(|event| (event, raw.kind.is_utd()))
})
.collect();
unread_verdict(own_user_id, read_event_id.as_deref(), is_encrypted, &events)
}
/// One timeline event as `unread_verdict` sees it: the parsed event and
/// whether the SDK flagged it undecryptable, or `None` when it would not
/// parse.
type ParsedEvent = Option<(matrix_sdk::ruma::events::AnySyncTimelineEvent, bool)>;
/// The decision half of [`room_unread_state`], on plain data so it needs no
/// client or room. `events` is newest first.
fn unread_verdict(
own_user_id: &matrix_sdk::ruma::UserId,
read_event_id: Option<&matrix_sdk::ruma::EventId>,
is_encrypted: bool,
events: &[ParsedEvent],
) -> Option<(OwnedEventId, OwnedUserId, String)> {
for slot in events {
let Some((event, is_utd)) = slot else {
// Unparseable — same as the old filter-then-scan behaviour:
// skip it rather than let it match the read receipt or count
// as the fallback's "newest".
continue;
};
if read_event_id == Some(event.event_id()) {
// Scanned back to the agent's own read receipt without
// hitting anything intentional first — proven read, not
// guessed: there cannot be an unread intentional event in a
// range we've fully walked.
return None;
}
if !is_intentional(event) {
continue;
}
if event.sender().as_str() == own_user_id.as_str() {
return None;
}
let body = utd_gated_body(is_encrypted, *is_utd, event);
return Some((event.event_id().to_owned(), event.sender().to_owned(), body));
}
// Exhausted the lookback window without hitting the receipt or an
// intentional event — genuinely unknown (the receipt is further
// back than we scanned). Falls back to the pre-fix behaviour
// (surface the newest event, whatever its type) rather than
// guessing "read": a false "unread" just costs a wasted wake, a
// false "read" risks silently swallowing a real message. Expected
// to be rare now that the state-churn source this fix targets is
// itself bounded.
let (newest, is_utd) = events.first()?.as_ref()?;
if newest.sender().as_str() == own_user_id.as_str() {
return None;
}
let body = utd_gated_body(is_encrypted, *is_utd, newest);
Some((
newest.event_id().to_owned(),
newest.sender().to_owned(),
body,
))
}
/// UTD-gated body, same rule `to_timeline_event` uses for `read_room`: an
/// encrypted room's genuinely-undecryptable event gets a sentinel that
/// still varies per event; everything else (plaintext rooms, and
/// successfully-decrypted events in an encrypted one) gets its real
/// extracted body.
///
/// The sentinel can't be a constant string. Two different UTD events
/// (the normal e2ee failure mode — a new device, a missing megolm
/// session, late-arriving keys) would otherwise produce byte-identical
/// summaries, and the harness's content-diff upsert would never re-wake
/// on the second one — the same silent-deafness failure as an undecrypted
/// room reporting an empty body for every event, just with a different
/// unchanging text. Folding the event id into the sentinel is cheap and
/// needs no new dedup-key plumbing.
fn utd_gated_body(
is_encrypted: bool,
is_utd: bool,
parsed: &matrix_sdk::ruma::events::AnySyncTimelineEvent,
) -> String {
if is_encrypted && is_utd {
format!("[unable to decrypt] ({})", parsed.event_id())
} else {
extract_body_sync(parsed)
}
}
pub async fn send_message(client: &Client, room_ref: &str, body: &str) -> DaemonResponse {
let room = match resolve_room(client, room_ref).await {
Ok(r) => r,
Err(e) => return e,
};
if let Some(reject) = unread_guard(client, &room).await {
return reject;
}
let content = RoomMessageEventContent::text_markdown(body);
match room.send(content).await {
Ok(resp) => DaemonResponse::ok(&serde_json::json!({
"event_id": resp.response.event_id.to_string(),
"room_id": room.room_id().to_string(),
})),
Err(e) => DaemonResponse::error(format!("send to {}: {e}", room.room_id())),
}
}
/// Where the other side of a DM stands in the room.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Recipient {
/// In the room: they see what is sent.
Joined,
/// Invited and not yet accepted: they see it only once they join.
Invited,
/// Neither (never invited, declined and left, or the invite failed):
/// anything sent goes to nobody.
Absent,
}
impl Recipient {
fn label(self) -> &'static str {
match self {
Self::Joined => "joined",
Self::Invited => "invited",
Self::Absent => "absent",
}
}
}
/// `uid`'s standing among `members` (the room's active members).
fn recipient_among(
members: impl IntoIterator<Item = (OwnedUserId, MembershipState)>,
uid: &OwnedUserId,
) -> Recipient {
members
.into_iter()
.find(|(member, _)| member == uid)
.map_or(Recipient::Absent, |(_, state)| match state {
MembershipState::Join => Recipient::Joined,
MembershipState::Invite => Recipient::Invited,
_ => Recipient::Absent,
})
}
/// A bare agent name (`iris`, no `@` and no `:`) means that agent's account
/// on the caller's own homeserver, `@iris:<own server>`; anything else is
/// passed through for the normal user id parse. Lets a simple agent DM a peer
/// by the name it knows it by.
fn expand_user_id(own_server: Option<&str>, input: &str) -> String {
match own_server {
Some(server) if !input.is_empty() && !input.contains(['@', ':']) => {
format!("@{input}:{server}")
}
_ => input.to_owned(),
}
}
/// Find the existing DM room with `user_id` (a full id, or a bare agent name,
/// see [`expand_user_id`]) or create one, and say where the
/// recipient stands in it. Shared by `send_dm` and `open_dm`.
/// `direct_targets()` (cached state) is used rather than the async
/// `is_direct()`.
///
/// A DM nobody is in is an error, not a success: creating a room succeeds
/// even when the invite to the other user did not, and a message sent there
/// is delivered to no one.
async fn resolve_or_create_dm(
client: &Client,
user_id: &str,
) -> Result<(matrix_sdk::Room, Recipient, OwnedUserId), DaemonResponse> {
let own = client.user_id().map(|u| u.server_name().to_string());
let user_id = expand_user_id(own.as_deref(), user_id);
let uid: OwnedUserId = user_id
.parse()
.map_err(|e| DaemonResponse::error(format!("invalid user_id {user_id}: {e}")))?;
let room = match client.joined_rooms().into_iter().find(|r| {
r.direct_targets()
.iter()
.any(|t| t.as_str() == uid.as_str())
}) {
Some(room) => room,
None => client
.create_dm(&uid)
.await
.map_err(|e| DaemonResponse::error(format!("create_dm {uid}: {e}")))?,
};
let members = room.members(RoomMemberships::ACTIVE).await.map_err(|e| {
DaemonResponse::error(format!("list members of DM {}: {e}", room.room_id()))
})?;
let recipient = recipient_among(
members
.iter()
.map(|m| (m.user_id().to_owned(), m.membership().clone())),
&uid,
);
if recipient == Recipient::Absent {
return Err(DaemonResponse::error(format!(
"{uid} is not in DM room {}: the invite did not go through, or they left. Nothing was sent.",
room.room_id()
)));
}
Ok((room, recipient, uid))
}
/// The JSON fields that tell the caller where the recipient stands, with a
/// `warning` while they have not joined yet.
fn recipient_fields(recipient: Recipient) -> serde_json::Value {
let mut fields = serde_json::json!({ "recipient": recipient.label() });
if recipient == Recipient::Invited {
fields["warning"] = serde_json::json!(
"the recipient has been invited but has not joined; they will only see this once they accept"
);
}
fields
}
pub async fn send_dm(client: &Client, user_id: &str, body: &str) -> DaemonResponse {
let (room, recipient, uid) = match resolve_or_create_dm(client, user_id).await {
Ok(r) => r,
Err(e) => return e,
};
if let Some(reject) = unread_guard(client, &room).await {
return reject;
}
let content = RoomMessageEventContent::text_markdown(body);
match room.send(content).await {
Ok(resp) => {
let mut payload = serde_json::json!({
"event_id": resp.response.event_id.to_string(),
"room_id": room.room_id().to_string(),
"user_id": uid.to_string(),
});
merge(&mut payload, recipient_fields(recipient));
DaemonResponse::ok(&payload)
}
Err(e) => DaemonResponse::error(format!("send DM to {uid}: {e}")),
}
}
/// Maximum file size accepted by `send_file`.
const MAX_UPLOAD_BYTES: u64 = 50 * 1024 * 1024;
/// Read a local file and post it as a matrix attachment to `room`,
/// inferring the MIME type from the file extension. An optional
/// `caption` is sent as a best-effort follow-up text message (the
/// attachment has already landed, so a caption failure is non-fatal).
/// Used by `send_file`.
async fn upload_attachment(
room: &matrix_sdk::Room,
path: &str,
caption: Option<&str>,
) -> DaemonResponse {
let p = std::path::Path::new(path);
let meta = match tokio::fs::metadata(p).await {
Ok(m) => m,
Err(e) => return DaemonResponse::error(format!("stat {path}: {e}")),
};
if !meta.is_file() {
return DaemonResponse::error(format!("{path} is not a regular file"));
}
if meta.len() > MAX_UPLOAD_BYTES {
return DaemonResponse::error(format!(
"{path} is {} bytes, over the {MAX_UPLOAD_BYTES}-byte upload cap",
meta.len()
));
}
let data = match tokio::fs::read(p).await {
Ok(d) => d,
Err(e) => return DaemonResponse::error(format!("read {path}: {e}")),
};
let filename = p
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file")
.to_owned();
let content_type: mime::Mime = mime_guess::from_path(p).first_or_octet_stream();
let resp = match room
.send_attachment(
filename.clone(),
&content_type,
data,
matrix_sdk::attachment::AttachmentConfig::new(),
)
.await
{
Ok(r) => r,
Err(e) => {
return DaemonResponse::error(format!("send attachment to {}: {e}", room.room_id()));
}
};
if let Some(c) = caption.filter(|c| !c.is_empty()) {
let _ = room.send(RoomMessageEventContent::text_markdown(c)).await;
}
DaemonResponse::ok(&serde_json::json!({
"event_id": resp.event_id.to_string(),
"room_id": room.room_id().to_string(),
"filename": filename,
"mime": content_type.essence_str(),
}))
}
pub async fn send_file(
client: &Client,
room_ref: &str,
path: &str,
caption: Option<&str>,
) -> DaemonResponse {
let room = match resolve_room(client, room_ref).await {
Ok(r) => r,
Err(e) => return e,
};
if let Some(reject) = unread_guard(client, &room).await {
return reject;
}
upload_attachment(&room, path, caption).await
}
/// Resolve (find-or-create) the DM room with `user_id` and return its
/// room id without sending anything. The caller then uses the room-based
/// tools (`send_file`, `send_message`, …) against that id — so there is
/// no per-tool `_dm` variant.
pub async fn open_dm(client: &Client, user_id: &str) -> DaemonResponse {
let (room, recipient, uid) = match resolve_or_create_dm(client, user_id).await {
Ok(r) => r,
Err(e) => return e,
};
let mut payload = serde_json::json!({
"room_id": room.room_id().to_string(),
"user_id": uid.to_string(),
});
merge(&mut payload, recipient_fields(recipient));
DaemonResponse::ok(&payload)
}
/// Copy `extra`'s keys into `target` (both JSON objects).
fn merge(target: &mut serde_json::Value, extra: serde_json::Value) {
if let (Some(target), serde_json::Value::Object(extra)) = (target.as_object_mut(), extra) {
target.extend(extra);
}
}
pub async fn send_reaction(
client: &Client,
room_ref: &str,
event_id: &str,
key: &str,
) -> DaemonResponse {
let room = match resolve_room(client, room_ref).await {
Ok(r) => r,
Err(e) => return e,
};
let eid: OwnedEventId = match event_id.parse() {
Ok(e) => e,
Err(e) => return DaemonResponse::error(format!("invalid event_id {event_id}: {e}")),
};
let content = ReactionEventContent::new(Annotation::new(eid.clone(), key.to_owned()));
match room.send(content).await {
Ok(resp) => DaemonResponse::ok(&serde_json::json!({
"event_id": resp.response.event_id.to_string(),
"target": eid.to_string(),
})),
Err(e) => DaemonResponse::error(format!("send reaction to {eid}: {e}")),
}
}
pub async fn send_reply(
client: &Client,
room_ref: &str,
event_id: &str,
body: &str,
) -> DaemonResponse {
let room = match resolve_room(client, room_ref).await {
Ok(r) => r,
Err(e) => return e,
};
if let Some(reject) = unread_guard(client, &room).await {
return reject;
}
let eid: OwnedEventId = match event_id.parse() {
Ok(e) => e,
Err(e) => return DaemonResponse::error(format!("invalid event_id {event_id}: {e}")),
};
let content = RoomMessageEventContent::text_markdown(body).into();
let reply = Reply {
event_id: eid.clone(),
enforce_thread: EnforceThread::MaybeThreaded,
// Add the replied-to sender to m.mentions (standard reply UX — the
// SDK downgrades this to No when replying to your own event).
add_mentions: AddMentions::Yes,
};
let reply_content = match room.make_reply_event(content, reply).await {
Ok(c) => c,
Err(e) => return DaemonResponse::error(format!("make_reply_event: {e}")),
};
match room.send(reply_content).await {
Ok(resp) => DaemonResponse::ok(&serde_json::json!({
"event_id": resp.response.event_id.to_string(),
"target": eid.to_string(),
})),
Err(e) => DaemonResponse::error(format!("send reply: {e}")),
}
}
pub async fn mark_read(client: &Client, room_ref: &str, event_id: &str) -> DaemonResponse {
let room = match resolve_room(client, room_ref).await {
Ok(r) => r,
Err(e) => return e,
};
let eid: OwnedEventId = match event_id.parse() {
Ok(e) => e,
Err(e) => return DaemonResponse::error(format!("invalid event_id {event_id}: {e}")),
};
match room
.send_single_receipt(ReceiptType::Read, ReceiptThread::Unthreaded, eid.clone())
.await
{
Ok(()) => DaemonResponse::ok(&serde_json::json!({
"marked_read": eid.to_string(),
})),
Err(e) => DaemonResponse::error(format!("send_single_receipt: {e}")),
}
}
pub async fn send_redact(
client: &Client,
room_ref: &str,
event_id: &str,
reason: Option<&str>,
) -> DaemonResponse {
let room = match resolve_room(client, room_ref).await {
Ok(r) => r,
Err(e) => return e,
};
let eid: OwnedEventId = match event_id.parse() {
Ok(e) => e,
Err(e) => return DaemonResponse::error(format!("invalid event_id {event_id}: {e}")),
};
// Redaction is irreversible: the homeserver drops the event content and
// keeps only the shell + reason. A fresh txn id (None) is fine — this is
// a one-shot operator/agent action, not a retried send. Power level is
// enforced server-side (own events always; others' need a moderator PL),
// so a redact that races concurrent delivery is resolved by the server.
// No client-side pre-check — we let the server be the authority on both
// permission and ordering.
match room.redact(&eid, reason, None).await {
Ok(resp) => DaemonResponse::ok(&serde_json::json!({
"event_id": resp.event_id.to_string(),
"target": eid.to_string(),
})),
Err(e) => DaemonResponse::error(format!("redact {eid}: {e}")),
}
}
pub async fn list_rooms(client: &Client) -> DaemonResponse {
let mut rooms = Vec::new();
for room in client.joined_rooms() {
let name = match room.display_name().await {
Ok(n) => n.to_string(),
Err(_) => room.room_id().to_string(),
};
let canonical_alias = room.canonical_alias().map(|a| a.to_string());
rooms.push(RoomInfo {
room_id: room.room_id().to_string(),
canonical_alias,
name,
member_count: room.joined_members_count(),
});
}
DaemonResponse::ok(&rooms)
}
pub async fn list_room_members(client: &Client, room_ref: &str) -> DaemonResponse {
let room = match resolve_room(client, room_ref).await {
Ok(r) => r,
Err(e) => return e,
};
let members = match room.members(matrix_sdk::RoomMemberships::JOIN).await {
Ok(m) => m,
Err(e) => return DaemonResponse::error(format!("members: {e}")),
};
let list: Vec<MemberInfo> = members
.iter()
.map(|m| MemberInfo {
user_id: m.user_id().to_string(),
display_name: m.display_name().map(ToOwned::to_owned),
})
.collect();
DaemonResponse::ok(&list)
}
pub async fn list_invites(client: &Client) -> DaemonResponse {
let mut pending = Vec::new();
for room in client.invited_rooms() {
let inviter = room
.invite_details()
.await
.map(|details| details.inviter_id.to_string())
.ok();
pending.push(InviteInfo {
room_id: room.room_id().to_string(),
canonical_alias: room.canonical_alias().map(|a| a.to_string()),
name: room.name(),
inviter,
topic: room.topic(),
});
}
DaemonResponse::ok(&pending)
}
pub async fn join_room(client: &Client, room_ref: &str) -> DaemonResponse {
let parsed: &RoomOrAliasId = match room_ref.try_into() {
Ok(p) => p,
Err(e) => {
return DaemonResponse::error(format!("invalid room reference {room_ref}: {e}"));
}
};
let server_names: Vec<OwnedServerName> = vec![];
match client.join_room_by_id_or_alias(parsed, &server_names).await {
Ok(room) => {
// Clear the invite todo so the accepted invite drops out of
// `get_loose_ends` immediately (the sweep would also clear it
// on its next tick, but this makes it instant).
let _ =
crate::wake::send_todo_clear(&crate::timeline::invite_key(room.room_id())).await;
DaemonResponse::ok(&serde_json::json!({
"joined": true,
"room_id": room.room_id().to_string(),
}))
}
Err(e) => DaemonResponse::error(format!("join room {room_ref}: {e}")),
}
}
/// Accept or reject a pending invite to `room_ref` (id or alias).
///
/// `Accept` is exactly `join_room` (joining a room you were invited to
/// accepts the invite). `Reject` resolves the invited room and leaves
/// it, declining the invite. Either way the resolved invite drops out
/// of `get_loose_ends` immediately.
pub async fn resolve_invite(
client: &Client,
room_ref: &str,
action: InviteAction,
) -> DaemonResponse {
match action {
InviteAction::Accept => join_room(client, room_ref).await,
InviteAction::Reject => {
// `resolve_room` -> `get_room` returns the room in any
// membership state the store knows, including `Invited`, so
// this works for a pending invite that was never joined.
let room = match resolve_room(client, room_ref).await {
Ok(r) => r,
Err(e) => return e,
};
match room.leave().await {
Ok(()) => {
// Clear the invite todo immediately on reject.
let _ =
crate::wake::send_todo_clear(&crate::timeline::invite_key(room.room_id()))
.await;
DaemonResponse::ok(&serde_json::json!({
"rejected": true,
"room_id": room.room_id().to_string(),
}))
}
Err(e) => DaemonResponse::error(format!("reject invite {room_ref}: {e}")),
}
}
}
}
/// Invite `user_id` to `room_ref`. The calling agent must be a member of
/// the room with a power level high enough to invite; otherwise the
/// homeserver rejects it and the error is surfaced verbatim.
pub async fn invite_user(client: &Client, room_ref: &str, user_id: &str) -> DaemonResponse {
let uid: OwnedUserId = match user_id.parse() {
Ok(u) => u,
Err(e) => return DaemonResponse::error(format!("invalid user_id {user_id}: {e}")),
};
let room = match resolve_room(client, room_ref).await {
Ok(r) => r,
Err(e) => return e,
};
match room.invite_user_by_id(&uid).await {
Ok(()) => DaemonResponse::ok(&serde_json::json!({
"invited": true,
"room_id": room.room_id().to_string(),
"user_id": uid.to_string(),
})),
Err(e) => DaemonResponse::error(format!("invite {uid} to {room_ref}: {e}")),
}
}
/// Map a matrix-sdk timeline event to the JSON DTO, applying the
/// encryption-gated UTD sentinel. Returns `None` when the raw event can't
/// be deserialized. Shared by `read_room`'s plain-timeline and the
/// event-anchored (`from` / `until`) paths.
///
/// `room.messages()` / `event_with_context` transparently decrypt events in
/// encrypted rooms. UTD (unable to decrypt) events surface via
/// `ev.kind.is_utd()`, but that flag can transiently fire in a NON-encrypted
/// room when the SDK hasn't finished reclassifying a freshly-synced
/// `m.room.encrypted` cache row — so gate the sentinel on the room actually
/// being encrypted to avoid a false `[unable to decrypt]` on plaintext.
fn to_timeline_event(
ev: &matrix_sdk::deserialized_responses::TimelineEvent,
is_encrypted: bool,
) -> Option<TimelineEvent> {
let parsed = ev.raw().deserialize().ok()?;
let replacement = replacement_of(&parsed);
let body = if is_encrypted && ev.kind.is_utd() {
"[unable to decrypt]".to_owned()
} else if let Some((_, new_body)) = &replacement {
new_body.clone()
} else {
extract_body_sync(&parsed)
};
Some(TimelineEvent {
event_id: parsed.event_id().to_string(),
sender: parsed.sender().to_string(),
origin_server_ts: parsed.origin_server_ts().0.into(),
event_type: parsed.event_type().to_string(),
body,
in_reply_to_event_id: extract_in_reply_to(&parsed),
edited: false,
replaces_event_id: replacement.map(|(target, _)| target),
})
}
/// Read room timeline events. With neither cursor set, returns the last
/// `limit` events (newest-first) — the default. `from` / `until` anchor the
/// read at an event id (mutually exclusive):
/// - `until = event_id` → the anchor plus the `limit - 1` events *before* it
/// (page into the past from a known event).
/// - `from = event_id` → the anchor plus the `limit - 1` events *after* it
/// (continue reading forward from a known point).
///
/// Both cursors resolve the anchor via the `/context` endpoint
/// (`event_with_context`) to get a pagination token, then page precisely in
/// the requested direction — matrix `/messages` `from` is an opaque token,
/// not an event id, so plain `messages()` can't anchor at an event. All
/// three modes return events newest-first for a consistent shape.
pub async fn read_room(
client: &Client,
room_ref: &str,
limit: Option<usize>,
from: Option<String>,
until: Option<String>,
) -> DaemonResponse {
use matrix_sdk::room::MessagesOptions;
use matrix_sdk::ruma::UInt;
let room = match resolve_room(client, room_ref).await {
Ok(r) => r,
Err(e) => return e,
};
if from.is_some() && until.is_some() {
return DaemonResponse::error(
"read_room: `from` and `until` are mutually exclusive \
(they page opposite directions off the anchor event)"
.to_owned(),
);
}
// Clamp to [1, 200]; keep it in u32 so every `UInt` conversion below is
// infallible (`limit_val - 1` can't underflow — the clamp floor is 1).
let limit_val: u32 = u32::try_from(limit.unwrap_or(50))
.unwrap_or(50)
.clamp(1, 200);
let is_encrypted = room.encryption_state().is_encrypted();
let events: Vec<TimelineEvent> = if let Some(anchor_ref) = from.as_deref().or(until.as_deref())
{
let eid: OwnedEventId = match anchor_ref.parse() {
Ok(e) => e,
Err(e) => return DaemonResponse::error(format!("invalid event_id {anchor_ref}: {e}")),
};
// context_size 0: no context events, so the returned tokens point
// immediately around the anchor and the follow-up page is contiguous.
let ctx = match room
.event_with_context(&eid, false, UInt::from(0u32), None)
.await
{
Ok(c) => c,
Err(e) => return DaemonResponse::error(format!("event_with_context {eid}: {e}")),
};
let Some(anchor) = ctx.event else {
return DaemonResponse::error(format!("event {eid} not found in room"));
};
let side_limit = UInt::from(limit_val - 1);
if from.is_some() {
// Forward (newer) from the anchor. The forward chunk is
// oldest-first; reverse to newest-first with the anchor (oldest of
// the slice) last. When the anchor is at the live edge there is no
// forward token — return the anchor alone rather than pass
// `from = None` to messages(), which would page from room *start*.
let mut out: Vec<TimelineEvent> = Vec::new();
if let Some(token) = ctx.next_batch_token {
let mut opts = MessagesOptions::forward();
opts.from = Some(token);
opts.limit = side_limit;
let newer = match room.messages(opts).await {
Ok(m) => m,
Err(e) => return DaemonResponse::error(format!("messages (from): {e}")),
};
out.extend(
newer
.chunk
.iter()
.rev()
.filter_map(|ev| to_timeline_event(ev, is_encrypted)),
);
}
out.extend(to_timeline_event(&anchor, is_encrypted));
out
} else {
// Backward (older) to the anchor. Backward chunk is already
// newest-first; the anchor is the newest event, so it leads. When
// the anchor is the oldest event there is no backward token —
// return the anchor alone rather than pass `from = None`, which
// would page from room *end* (the newest events).
let mut out: Vec<TimelineEvent> = to_timeline_event(&anchor, is_encrypted)
.into_iter()
.collect();
if let Some(token) = ctx.prev_batch_token {
let mut opts = MessagesOptions::backward();
opts.from = Some(token);
opts.limit = side_limit;
let older = match room.messages(opts).await {
Ok(m) => m,
Err(e) => return DaemonResponse::error(format!("messages (until): {e}")),
};
out.extend(
older
.chunk
.iter()
.filter_map(|ev| to_timeline_event(ev, is_encrypted)),
);
}
out
}
} else {
let mut opts = MessagesOptions::backward();
opts.limit = UInt::from(limit_val);
let msgs = match room.messages(opts).await {
Ok(m) => m,
Err(e) => return DaemonResponse::error(format!("messages: {e}")),
};
msgs.chunk
.iter()
.filter_map(|ev| to_timeline_event(ev, is_encrypted))
.collect()
};
DaemonResponse::ok(&collapse_edits(events))
}
/// The media source, file name and caption of an `m.file` / `m.image` /
/// `m.audio` / `m.video` message; `None` for any other type. The event's
/// `body` is the caption when the sender gave one, so the name comes from
/// `filename()` (which falls back to `body` for senders that set no
/// `filename`).
fn media_parts(
msgtype: &MessageType,
) -> Option<(
matrix_sdk::ruma::events::room::MediaSource,
String,
Option<String>,
)> {
let (source, name, caption) = match msgtype {
MessageType::File(f) => (&f.source, f.filename(), f.caption()),
MessageType::Image(i) => (&i.source, i.filename(), i.caption()),
MessageType::Audio(a) => (&a.source, a.filename(), a.caption()),
MessageType::Video(v) => (&v.source, v.filename(), v.caption()),
_ => return None,
};
Some((source.clone(), name.to_owned(), caption.map(str::to_owned)))
}
/// The last path segment of a sender-supplied attachment name, so it cannot
/// point outside the temp dir; `matrix-attachment` when nothing usable is
/// left.
fn safe_file_name(name: &str) -> &str {
std::path::Path::new(name)
.file_name()
.and_then(|n| n.to_str())
.filter(|n| !n.is_empty())
.unwrap_or("matrix-attachment")
}
/// Download the media attachment carried by `event_id` in `room` and
/// write it to a local file, returning the path. Resolves the event,
/// extracts the `MediaSource` from its `m.file` / `m.image` / `m.audio`
/// / `m.video` content, fetches the bytes via the media API, and writes
/// them to `dest_path` (or a temp file named after the attachment when
/// omitted). The agent then reads the file from the returned path.
pub async fn download_file(
client: &Client,
room_ref: &str,
event_id: &str,
dest_path: Option<&str>,
) -> DaemonResponse {
use matrix_sdk::media::{MediaFormat, MediaRequestParameters};
use matrix_sdk::ruma::events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent};
let room = match resolve_room(client, room_ref).await {
Ok(r) => r,
Err(e) => return e,
};
let eid: OwnedEventId = match event_id.parse() {
Ok(e) => e,
Err(e) => return DaemonResponse::error(format!("invalid event_id {event_id}: {e}")),
};
let ev = match room.event(&eid, None).await {
Ok(e) => e,
Err(e) => return DaemonResponse::error(format!("fetch event {eid}: {e}")),
};
let parsed = match ev.raw().deserialize() {
Ok(p) => p,
Err(e) => return DaemonResponse::error(format!("deserialize event {eid}: {e}")),
};
// Pull the media source + filename from the message content.
let AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(msg)) = parsed
else {
return DaemonResponse::error(format!("event {eid} is not a room message"));
};
let Some(orig) = msg.as_original() else {
return DaemonResponse::error(format!("event {eid} is redacted"));
};
let Some((source, filename, caption)) = media_parts(&orig.content.msgtype) else {
return DaemonResponse::error(format!(
"event {eid} carries no file/image/audio/video attachment"
));
};
let req = MediaRequestParameters {
source,
format: MediaFormat::File,
};
let bytes = match client.media().get_media_content(&req, true).await {
Ok(b) => b,
Err(e) => return DaemonResponse::error(format!("download media for {eid}: {e}")),
};
let filename = safe_file_name(&filename);
let dest = if let Some(p) = dest_path {
std::path::PathBuf::from(p)
} else {
std::env::temp_dir().join(filename)
};
if let Err(e) = tokio::fs::write(&dest, &bytes).await {
return DaemonResponse::error(format!("write {}: {e}", dest.display()));
}
DaemonResponse::ok(&serde_json::json!({
"path": dest.display().to_string(),
"filename": filename,
"caption": caption,
"bytes": bytes.len(),
"room_id": room.room_id().to_string(),
}))
}
/// Collect all rooms with unread notifications and build per-room
/// [`crate::protocol::RoomUnread`] entries. For rooms with exactly
/// one unread notification the last message body is fetched via the
/// `/messages` endpoint (best-effort; failures leave `last_body` as
/// `None`). Rooms with zero unreads are omitted.
///
/// **Latency note**: each unread room triggers a live `/messages`
/// network request to the matrix homeserver to determine its latest
/// event (via `room_unread_state`). This adds per-room round-trip
/// latency to `get_loose_ends` and the wake-signal path. Acceptable in
/// practice (rooms with unread are few; request is best-effort), but
/// worth bearing in mind if latency becomes a concern.
#[must_use]
pub async fn collect_unread(client: &Client) -> Vec<crate::protocol::RoomUnread> {
collect_unread_with_ids(client)
.await
.into_iter()
.map(|(_, ru)| ru)
.collect()
}
/// Like [`collect_unread`] but pairs each entry with its `OwnedRoomId`.
/// The todo producer (loose-ends v2) needs the room id as the
/// per-room upsert/dedup key, which the claude-facing `RoomUnread`
/// payload intentionally doesn't carry.
#[must_use]
pub async fn collect_unread_with_ids(
client: &Client,
) -> Vec<(matrix_sdk::ruma::OwnedRoomId, crate::protocol::RoomUnread)> {
collect_unread_in(client, client.joined_rooms()).await
}
/// [`collect_unread_with_ids`] over just `rooms`. Each room costs one live
/// `/messages` fetch (see `recent_events`), so the post-sync sweep passes
/// only the rooms the sync touched.
#[must_use]
pub async fn collect_unread_in(
client: &Client,
rooms: Vec<matrix_sdk::Room>,
) -> Vec<(matrix_sdk::ruma::OwnedRoomId, crate::protocol::RoomUnread)> {
use crate::protocol::RoomUnread;
let mut result = Vec::new();
for room in rooms {
let Some((_, sender, body)) = room_unread_state(client, &room).await else {
continue;
};
let label = room_label(&room);
let last_body = (!body.is_empty()).then(|| crate::wake::truncate_chars(&body, 100));
result.push((
room.room_id().to_owned(),
RoomUnread {
label,
count: 1,
last_body,
last_sender: Some(sender.to_string()),
},
));
}
result
}
/// Return per-room unread summaries. Each entry carries the sender +
/// truncated body of the room's latest (unread) event when it's a
/// text-like message; rooms whose latest unread event has no body
/// (a reaction, a state change, …) carry the room + sender only.
#[must_use]
pub async fn unread_summary(client: &Client) -> DaemonResponse {
let rooms = collect_unread(client).await;
DaemonResponse::ok(&rooms)
}
#[cfg(test)]
mod utd_body_tests {
use super::utd_gated_body;
/// Parses a minimal `m.room.message` sync event with the given event
/// id and body, enough for `utd_gated_body`'s plaintext path
/// (`extract_body_sync`) and the UTD path (which only reads the event
/// id) alike.
fn text_event(event_id: &str, body: &str) -> matrix_sdk::ruma::events::AnySyncTimelineEvent {
serde_json::from_value(serde_json::json!({
"type": "m.room.message",
"event_id": event_id,
"sender": "@alice:example.org",
"origin_server_ts": 1,
"content": {"msgtype": "m.text", "body": body},
}))
.expect("valid minimal m.room.message")
}
#[test]
fn two_utd_events_get_different_bodies() {
let a = utd_gated_body(true, true, &text_event("$a:example.org", "ciphertext a"));
let b = utd_gated_body(true, true, &text_event("$b:example.org", "ciphertext b"));
assert_ne!(
a, b,
"two different undecryptable events must not collapse to the \
same summary text, or the harness's content-diff upsert never \
re-wakes on the second one"
);
assert!(a.contains("$a:example.org"));
assert!(b.contains("$b:example.org"));
}
#[test]
fn decrypted_event_in_an_encrypted_room_uses_its_real_body() {
let got = utd_gated_body(true, false, &text_event("$a:example.org", "hello"));
assert_eq!(got, "hello");
}
#[test]
fn plaintext_room_ignores_the_utd_flag() {
// `is_utd` can transiently fire in a non-encrypted room while the
// SDK finishes reclassifying a freshly-synced cache row (see
// `to_timeline_event`'s doc comment) — `is_encrypted` gates it.
let got = utd_gated_body(false, true, &text_event("$a:example.org", "hello"));
assert_eq!(got, "hello");
}
}
#[cfg(test)]
mod unread_tests {
use super::{
ParsedEvent, TimelineEvent, attachment_marker, collapse_edits, expand_user_id,
extract_body_sync, extract_in_reply_to, is_intentional, media_parts, replacement_of,
safe_file_name, unread_verdict,
};
use matrix_sdk::ruma::events::AnySyncTimelineEvent;
use matrix_sdk::ruma::events::room::message::MessageType;
use matrix_sdk::ruma::{EventId, UserId, user_id};
use serde_json::json;
const ME: &str = "@me:example.org";
fn parse(value: serde_json::Value) -> AnySyncTimelineEvent {
serde_json::from_value(value).expect("valid sync event")
}
fn message(id: &str, sender: &str, body: &str) -> AnySyncTimelineEvent {
parse(json!({
"type": "m.room.message",
"event_id": id,
"sender": sender,
"origin_server_ts": 1,
"content": {"msgtype": "m.text", "body": body},
}))
}
fn space_child(id: &str, sender: &str) -> AnySyncTimelineEvent {
parse(json!({
"type": "m.space.child",
"state_key": "!child:example.org",
"event_id": id,
"sender": sender,
"origin_server_ts": 1,
"content": {"via": ["example.org"]},
}))
}
fn live(event: AnySyncTimelineEvent) -> (AnySyncTimelineEvent, bool) {
(event, false)
}
fn verdict(read: Option<&str>, events: &[ParsedEvent]) -> Option<(String, String, String)> {
let me: &UserId = user_id!("@me:example.org");
let read = read.map(|r| <&EventId>::try_from(r).expect("event id"));
unread_verdict(me, read, false, events)
.map(|(id, sender, body)| (id.to_string(), sender.to_string(), body))
}
#[test]
fn a_bare_agent_name_means_that_agent_on_the_own_homeserver() {
let own = Some("pr1ma.darkest.space");
assert_eq!(expand_user_id(own, "iris"), "@iris:pr1ma.darkest.space");
// Full ids and anything with a server part pass through untouched.
assert_eq!(expand_user_id(own, "@mara:matrix.org"), "@mara:matrix.org");
assert_eq!(expand_user_id(own, "iris:other.org"), "iris:other.org");
// No own server known, or nothing given: nothing to expand with.
assert_eq!(expand_user_id(None, "iris"), "iris");
assert_eq!(expand_user_id(own, ""), "");
}
#[test]
fn an_empty_room_is_caught_up() {
assert_eq!(verdict(None, &[]), None);
}
#[test]
fn a_message_from_someone_else_past_the_receipt_is_unread() {
let events = [
Some(live(message("$new:example.org", "@bob:example.org", "hi"))),
Some(live(message(
"$old:example.org",
"@bob:example.org",
"older",
))),
];
assert_eq!(
verdict(Some("$old:example.org"), &events),
Some((
"$new:example.org".into(),
"@bob:example.org".into(),
"hi".into()
))
);
}
#[test]
fn a_receipt_on_the_newest_event_means_caught_up() {
let events = [Some(live(message(
"$a:example.org",
"@bob:example.org",
"hi",
)))];
assert_eq!(verdict(Some("$a:example.org"), &events), None);
}
#[test]
fn the_agents_own_latest_message_never_counts_as_unread() {
// A read receipt that lags behind the agent's own just-sent
// message must not wake the agent for it.
let events = [Some(live(message("$mine:example.org", ME, "sent")))];
assert_eq!(verdict(None, &events), None);
}
fn edit(id: &str, sender: &str, target: &str, new_body: &str) -> AnySyncTimelineEvent {
parse(json!({
"type": "m.room.message",
"event_id": id,
"sender": sender,
"origin_server_ts": 2,
"content": {
"msgtype": "m.text",
"body": format!("* {new_body}"),
"m.new_content": {"msgtype": "m.text", "body": new_body},
"m.relates_to": {"rel_type": "m.replace", "event_id": target},
},
}))
}
fn timeline(id: &str, sender: &str, body: &str, replaces: Option<&str>) -> TimelineEvent {
TimelineEvent {
event_id: id.into(),
sender: sender.into(),
origin_server_ts: 1,
event_type: "m.room.message".into(),
body: body.into(),
in_reply_to_event_id: None,
edited: false,
replaces_event_id: replaces.map(Into::into),
}
}
#[test]
fn an_edit_carries_the_new_text_and_the_id_it_replaces() {
let ev = edit(
"$e:example.org",
"@bob:example.org",
"$o:example.org",
"fixed",
);
assert_eq!(
replacement_of(&ev),
Some(("$o:example.org".into(), "fixed".into()))
);
assert_eq!(
replacement_of(&message("$m:example.org", "@bob:example.org", "plain")),
None
);
}
#[test]
fn an_edit_is_not_a_second_unread_message() {
assert!(!is_intentional(&edit(
"$e:example.org",
"@bob:example.org",
"$o:example.org",
"fixed"
)));
// The edit sits newest; the walk skips it and lands on the
// original, so the room reports one unread message, not two.
let events = [
Some(live(edit(
"$e:example.org",
"@bob:example.org",
"$o:example.org",
"fixed",
))),
Some(live(message("$o:example.org", "@bob:example.org", "typo"))),
];
assert_eq!(
verdict(None, &events).map(|(id, _, _)| id),
Some("$o:example.org".into())
);
}
#[test]
fn an_edit_folds_into_its_original_and_the_duplicate_disappears() {
let got = collapse_edits(vec![
timeline(
"$e:example.org",
"@bob:example.org",
"fixed",
Some("$o:example.org"),
),
timeline("$other:example.org", "@amy:example.org", "unrelated", None),
timeline("$o:example.org", "@bob:example.org", "typo", None),
]);
let ids: Vec<&str> = got.iter().map(|e| e.event_id.as_str()).collect();
assert_eq!(ids, ["$other:example.org", "$o:example.org"]);
assert_eq!(got[1].body, "fixed");
assert!(got[1].edited);
assert!(!got[0].edited);
}
#[test]
fn the_latest_of_several_edits_wins() {
let got = collapse_edits(vec![
timeline(
"$e2:example.org",
"@bob:example.org",
"third",
Some("$o:example.org"),
),
timeline(
"$e1:example.org",
"@bob:example.org",
"second",
Some("$o:example.org"),
),
timeline("$o:example.org", "@bob:example.org", "first", None),
]);
assert_eq!(got.len(), 1);
assert_eq!(got[0].body, "third");
}
#[test]
fn someone_elses_edit_does_not_rewrite_a_message() {
let got = collapse_edits(vec![
timeline(
"$e:example.org",
"@mallory:example.org",
"forged",
Some("$o:example.org"),
),
timeline("$o:example.org", "@bob:example.org", "real", None),
]);
assert_eq!(
got.len(),
2,
"the forged edit stays visible as its own event"
);
assert_eq!(got[1].body, "real");
assert!(!got[1].edited);
}
#[test]
fn an_edit_whose_original_is_out_of_the_window_stays_and_says_what_it_replaces() {
let got = collapse_edits(vec![timeline(
"$e:example.org",
"@bob:example.org",
"fixed",
Some("$old:example.org"),
)]);
assert_eq!(got.len(), 1);
assert_eq!(
got[0].replaces_event_id.as_deref(),
Some("$old:example.org")
);
assert_eq!(got[0].body, "fixed");
}
#[test]
fn a_space_child_re_emit_is_skipped_to_reach_the_real_message() {
let events = [
Some(live(space_child("$churn:example.org", "@bot:example.org"))),
Some(live(message(
"$real:example.org",
"@bob:example.org",
"hello",
))),
];
assert_eq!(
verdict(None, &events),
Some((
"$real:example.org".into(),
"@bob:example.org".into(),
"hello".into()
))
);
}
#[test]
fn the_walk_stops_at_the_receipt_before_an_older_intentional_event() {
// Churn on top, then the receipt, then an older message: the
// receipt proves everything below it was read.
let events = [
Some(live(space_child("$churn:example.org", "@bot:example.org"))),
Some(live(message(
"$read:example.org",
"@bob:example.org",
"read",
))),
Some(live(message(
"$older:example.org",
"@bob:example.org",
"older",
))),
];
assert_eq!(verdict(Some("$read:example.org"), &events), None);
}
#[test]
fn a_window_of_only_churn_falls_back_to_the_newest_event() {
// The receipt is further back than the lookback: unknown, so the
// newest event is surfaced (a spare wake beats a swallowed message).
let events = [Some(live(space_child(
"$churn:example.org",
"@bot:example.org",
)))];
assert_eq!(
verdict(None, &events),
Some((
"$churn:example.org".into(),
"@bot:example.org".into(),
String::new()
))
);
}
#[test]
fn an_own_authored_newest_event_in_a_churn_window_is_not_unread() {
let events = [Some(live(space_child("$churn:example.org", ME)))];
assert_eq!(verdict(None, &events), None);
}
#[test]
fn an_unparseable_event_is_skipped_not_taken_for_the_newest() {
let events = [
None,
Some(live(message(
"$real:example.org",
"@bob:example.org",
"hello",
))),
];
assert_eq!(
verdict(None, &events),
Some((
"$real:example.org".into(),
"@bob:example.org".into(),
"hello".into()
))
);
}
#[test]
fn an_undecryptable_event_in_an_encrypted_room_gets_a_per_event_sentinel() {
let me: &UserId = user_id!("@me:example.org");
let events: [ParsedEvent; 1] = [Some((
message("$utd:example.org", "@bob:example.org", "ciphertext"),
true,
))];
let (_, _, body) = unread_verdict(me, None, true, &events).expect("unread");
assert_eq!(body, "[unable to decrypt] ($utd:example.org)");
}
#[test]
fn only_the_space_child_re_emit_is_unintentional() {
assert!(!is_intentional(&space_child(
"$c:example.org",
"@bot:example.org"
)));
assert!(is_intentional(&message(
"$m:example.org",
"@bob:example.org",
"x"
)));
let topic = parse(json!({
"type": "m.room.topic",
"state_key": "",
"event_id": "$t:example.org",
"sender": "@bob:example.org",
"origin_server_ts": 1,
"content": {"topic": "new"},
}));
assert!(is_intentional(&topic), "state changes are real activity");
}
fn msgtype(value: &serde_json::Value) -> MessageType {
MessageType::new(
value["msgtype"].as_str().expect("msgtype"),
value["body"].as_str().expect("body").to_owned(),
value.as_object().expect("object").clone(),
)
.expect("known msgtype")
}
#[test]
fn a_captioned_attachment_is_named_by_its_file_not_its_caption() {
for kind in ["m.file", "m.image", "m.audio", "m.video"] {
let captioned = msgtype(&json!({
"msgtype": kind,
"body": "here's the screenshot of the failing step",
"filename": "a.png",
"url": "mxc://example.org/a",
}));
let (_, name, caption) = media_parts(&captioned).expect(kind);
assert_eq!(name, "a.png", "{kind}");
assert_eq!(
caption.as_deref(),
Some("here's the screenshot of the failing step"),
"{kind}"
);
// A sender that sets no `filename` puts the name in `body`.
let plain = msgtype(&json!({
"msgtype": kind,
"body": "b.png",
"url": "mxc://example.org/a",
}));
let (_, name, caption) = media_parts(&plain).expect(kind);
assert_eq!((name.as_str(), caption), ("b.png", None), "{kind}");
}
assert!(media_parts(&msgtype(&json!({"msgtype": "m.text", "body": "x"}))).is_none());
}
#[test]
fn an_attachment_name_is_reduced_to_its_last_segment() {
assert_eq!(safe_file_name("a.png"), "a.png");
assert_eq!(safe_file_name("../../etc/x.png"), "x.png");
assert_eq!(safe_file_name("dir/"), "dir");
assert_eq!(safe_file_name(""), "matrix-attachment");
assert_eq!(safe_file_name(".."), "matrix-attachment");
}
#[test]
fn attachments_render_as_typed_markers_and_text_as_itself() {
let marker = |kind: &str, extra: serde_json::Value| {
let mut v = json!({"msgtype": kind, "body": "name.bin"});
v.as_object_mut()
.expect("object")
.extend(extra.as_object().expect("object").clone());
attachment_marker(&msgtype(&v))
};
assert_eq!(marker("m.text", json!({})), "name.bin");
assert_eq!(marker("m.notice", json!({})), "name.bin");
assert_eq!(marker("m.emote", json!({})), "* name.bin");
assert_eq!(
marker("m.file", json!({"url": "mxc://example.org/a"})),
"[file: name.bin]"
);
assert_eq!(
marker("m.image", json!({"url": "mxc://example.org/a"})),
"[image: name.bin]"
);
assert_eq!(
marker("m.audio", json!({"url": "mxc://example.org/a"})),
"[audio: name.bin]"
);
assert_eq!(
marker("m.video", json!({"url": "mxc://example.org/a"})),
"[video: name.bin]"
);
}
#[test]
fn a_non_message_event_has_no_body() {
assert_eq!(
extract_body_sync(&space_child("$c:example.org", "@bot:example.org")),
""
);
assert_eq!(
extract_body_sync(&message("$m:example.org", "@bob:example.org", "hello")),
"hello"
);
}
#[test]
fn reply_ids_come_from_direct_replies_and_thread_fallbacks_only() {
let with_relation = |relation: serde_json::Value| {
parse(json!({
"type": "m.room.message",
"event_id": "$r:example.org",
"sender": "@bob:example.org",
"origin_server_ts": 1,
"content": {"msgtype": "m.text", "body": "x", "m.relates_to": relation},
}))
};
assert_eq!(
extract_in_reply_to(&with_relation(
json!({"m.in_reply_to": {"event_id": "$target:example.org"}})
)),
Some("$target:example.org".to_owned())
);
assert_eq!(
extract_in_reply_to(&with_relation(json!({
"rel_type": "m.thread",
"event_id": "$root:example.org",
"is_falling_back": true,
"m.in_reply_to": {"event_id": "$fallback:example.org"},
}))),
Some("$fallback:example.org".to_owned())
);
assert_eq!(
extract_in_reply_to(&message("$m:example.org", "@bob:example.org", "plain")),
None
);
assert_eq!(
extract_in_reply_to(&space_child("$c:example.org", "@bot:example.org")),
None
);
}
}
#[cfg(test)]
mod recipient_tests {
use super::{Recipient, recipient_among, recipient_fields};
use matrix_sdk::ruma::OwnedUserId;
use matrix_sdk::ruma::events::room::member::MembershipState;
fn uid(id: &'static str) -> OwnedUserId {
OwnedUserId::try_from(id).expect("valid user id")
}
#[test]
fn a_joined_or_invited_recipient_is_reachable() {
let them = uid("@them:example.org");
let me = (uid("@me:example.org"), MembershipState::Join);
let joined = [me.clone(), (them.clone(), MembershipState::Join)];
let invited = [me, (them.clone(), MembershipState::Invite)];
assert_eq!(recipient_among(joined, &them), Recipient::Joined);
assert_eq!(recipient_among(invited, &them), Recipient::Invited);
}
#[test]
fn a_recipient_who_is_not_there_is_absent() {
let them = uid("@them:example.org");
let only_me = [(uid("@me:example.org"), MembershipState::Join)];
let left = [(them.clone(), MembershipState::Leave)];
let banned = [(them.clone(), MembershipState::Ban)];
assert_eq!(recipient_among(only_me, &them), Recipient::Absent);
assert_eq!(recipient_among(left, &them), Recipient::Absent);
assert_eq!(recipient_among(banned, &them), Recipient::Absent);
}
#[test]
fn only_a_pending_invite_carries_a_warning() {
assert_eq!(recipient_fields(Recipient::Joined)["recipient"], "joined");
assert!(recipient_fields(Recipient::Joined).get("warning").is_none());
let invited = recipient_fields(Recipient::Invited);
assert_eq!(invited["recipient"], "invited");
assert!(
invited["warning"]
.as_str()
.is_some_and(|w| w.contains("not joined"))
);
}
}