damocles-daemon/src/timeline.rs

313 lines
11 KiB
Rust

use std::collections::HashMap;
use matrix_sdk::{
Room,
ruma::{OwnedEventId, OwnedUserId, events::room::message::MessageType},
};
use crate::types::{EditRecord, TimelineItem};
/// Format a unix-seconds timestamp as `YYYY-MM-DD HH:MM` UTC. Returns "?" for 0.
pub fn format_ts(secs: i64) -> String {
if secs == 0 {
return "?".into();
}
let days = secs.div_euclid(86400);
let day_secs = secs.rem_euclid(86400);
let (y, m, d) = days_to_ymd(days);
let h = day_secs / 3600;
let min = (day_secs % 3600) / 60;
format!("{y:04}-{m:02}-{d:02} {h:02}:{min:02}")
}
pub fn chrono_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let days = secs / 86400;
let (y, m, d) = days_to_ymd(days);
format!("{y:04}-{m:02}-{d:02}")
}
/// Convert days-since-1970-01-01 to (year, month, day). Civil-date algorithm.
fn days_to_ymd(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(if m <= 2 { y + 1 } else { y }, m, d)
}
pub fn ts_secs_from(ts: matrix_sdk::ruma::UInt) -> i64 {
let ms: u64 = ts.into();
i64::try_from(ms).unwrap_or(0) / 1000
}
/// 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.
pub fn resolve_event_id(timeline: &[TimelineItem], arg: &str) -> Option<OwnedEventId> {
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
}
/// Load the last N timeline items (messages + reactions) from the room's
/// persistent event cache. Returns oldest-first.
///
/// We walk events newest-first, collect messages until we have `limit`, then
/// also include any reactions whose timestamps fall within the message window.
pub async fn load_timeline(
room: &Room,
limit: usize,
own_user: &OwnedUserId,
) -> anyhow::Result<Vec<TimelineItem>> {
use matrix_sdk::ruma::events::AnySyncTimelineEvent;
let (cache, _handles) = room.event_cache().await?;
let events = cache.events().await;
use matrix_sdk::ruma::events::room::message::Relation;
let mut messages: Vec<TimelineItem> = Vec::new();
let mut reactions: Vec<TimelineItem> = Vec::new();
// Edits stashed by target event_id. Walk is newest-first, so edits may
// arrive before their target original. (body, ts) pairs.
let mut pending_edits: HashMap<OwnedEventId, Vec<(String, i64)>> = HashMap::new();
let mut earliest_message_ts: Option<i64> = None;
for ev in events.iter().rev() {
let raw = ev.raw();
let Ok(deserialized) = raw.deserialize() else {
continue;
};
let AnySyncTimelineEvent::MessageLike(msg) = deserialized else {
continue;
};
match msg {
matrix_sdk::ruma::events::AnySyncMessageLikeEvent::RoomMessage(
matrix_sdk::ruma::events::SyncMessageLikeEvent::Original(orig),
) => {
let ts = ts_secs_from(orig.origin_server_ts.0);
// Edit event? Stash the new body for the target original; do
// NOT count toward the message limit.
if let Some(Relation::Replacement(replacement)) = &orig.content.relates_to {
if let MessageType::Text(text) = &replacement.new_content.msgtype {
pending_edits
.entry(replacement.event_id.clone())
.or_default()
.push((text.body.clone(), ts));
}
continue;
}
if messages.len() >= limit {
continue;
}
let MessageType::Text(text) = &orig.content.msgtype else {
continue;
};
let in_reply_to = match &orig.content.relates_to {
Some(Relation::Reply { in_reply_to }) => Some(in_reply_to.event_id.clone()),
_ => None,
};
if earliest_message_ts.is_none_or(|e| ts < e) {
earliest_message_ts = Some(ts);
}
messages.push(TimelineItem::Message {
event_id: orig.event_id.clone(),
sender: orig.sender.clone(),
body: text.body.clone(),
is_self: &orig.sender == own_user,
ts,
in_reply_to,
edit_history: Vec::new(),
});
}
matrix_sdk::ruma::events::AnySyncMessageLikeEvent::Reaction(
matrix_sdk::ruma::events::SyncMessageLikeEvent::Original(orig),
) => {
let ts = ts_secs_from(orig.origin_server_ts.0);
reactions.push(TimelineItem::Reaction {
sender: orig.sender.clone(),
target_event_id: orig.content.relates_to.event_id.clone(),
key: orig.content.relates_to.key.clone(),
is_self: &orig.sender == own_user,
ts,
});
}
_ => {}
}
}
// Apply pending edits to their target messages. Chain = [original body,
// edits sorted oldest-first]; current `body` becomes the latest, and
// everything before it ends up in `edit_history`.
for item in &mut messages {
if let TimelineItem::Message {
event_id,
body,
ts,
edit_history,
..
} = item
{
let edits = pending_edits.remove(event_id).unwrap_or_default();
if edits.is_empty() {
continue;
}
let mut chain: Vec<(String, i64)> = vec![(body.clone(), *ts)];
chain.extend(edits);
chain.sort_by_key(|(_, t)| *t);
// pop the most recent - that's the visible body
let (latest, _) = chain.pop().expect("chain has at least the original");
*body = latest;
*edit_history = chain
.into_iter()
.map(|(b, t)| EditRecord {
body: b,
ts: t,
ts_human: format!("{} UTC", format_ts(t)),
})
.collect();
}
}
if let Some(min_ts) = earliest_message_ts {
reactions.retain(|r| r.ts() >= min_ts);
}
let mut combined: Vec<TimelineItem> = Vec::with_capacity(messages.len() + reactions.len());
combined.extend(messages);
combined.extend(reactions);
combined.sort_by_key(TimelineItem::ts);
Ok(combined)
}
/// Fetch a single text message by event_id from the room's event cache.
pub async fn fetch_message(
cache: &matrix_sdk::event_cache::RoomEventCache,
event_id: &matrix_sdk::ruma::EventId,
own_user: &OwnedUserId,
) -> Option<TimelineItem> {
use matrix_sdk::ruma::events::AnySyncTimelineEvent;
let ev = cache.find_event(event_id).await?;
let deserialized = ev.raw().deserialize().ok()?;
let AnySyncTimelineEvent::MessageLike(msg) = deserialized else {
return None;
};
let matrix_sdk::ruma::events::AnySyncMessageLikeEvent::RoomMessage(
matrix_sdk::ruma::events::SyncMessageLikeEvent::Original(orig),
) = msg
else {
return None;
};
let MessageType::Text(text) = &orig.content.msgtype else {
return None;
};
let ts = ts_secs_from(orig.origin_server_ts.0);
Some(TimelineItem::Message {
event_id: orig.event_id.clone(),
sender: orig.sender.clone(),
body: text.body.clone(),
is_self: &orig.sender == own_user,
ts,
in_reply_to: None,
edit_history: Vec::new(),
})
}
/// For each message in the timeline, compute the list of OTHER users who
/// have a read receipt at or after that message. Self is excluded.
pub async fn compute_read_markers(
room: &Room,
timeline: &[TimelineItem],
own_user: &OwnedUserId,
) -> HashMap<OwnedEventId, Vec<OwnedUserId>> {
use matrix_sdk::ruma::events::receipt::{ReceiptThread, ReceiptType};
let mut users: Vec<OwnedUserId> = timeline
.iter()
.filter(|t| !t.is_self())
.map(|t| t.sender().clone())
.collect();
users.sort();
users.dedup();
let positions: HashMap<OwnedEventId, usize> = timeline
.iter()
.enumerate()
.filter_map(|(i, t)| match t {
TimelineItem::Message { event_id, .. } => Some((event_id.clone(), i)),
_ => None,
})
.collect();
let mut readers: HashMap<OwnedEventId, Vec<OwnedUserId>> = HashMap::new();
for user in &users {
if user == own_user {
continue;
}
let (receipt_eid, receipt_ts) = match room
.load_user_receipt(ReceiptType::Read, ReceiptThread::Unthreaded, user)
.await
{
Ok(Some((eid, r))) => {
let ts = r.ts.map(|t| ts_secs_from(t.0)).unwrap_or(0);
(eid, ts)
}
_ => continue,
};
let user_msg_idx_inclusive: Option<usize> = if let Some(&p) = positions.get(&receipt_eid) {
Some(p)
} else {
let newest_msg_ts = timeline
.iter()
.rev()
.find_map(|t| match t {
TimelineItem::Message { ts, .. } => Some(*ts),
_ => None,
})
.unwrap_or(0);
if receipt_ts > 0 && receipt_ts >= newest_msg_ts {
Some(timeline.len().saturating_sub(1))
} else {
None
}
};
if let Some(up_to) = user_msg_idx_inclusive {
for item in timeline.iter().take(up_to + 1) {
if let TimelineItem::Message { event_id, .. } = item {
readers
.entry(event_id.clone())
.or_default()
.push(user.clone());
}
}
}
}
readers
}