split main.rs into types, timeline, claude, handlers, session modules
This commit is contained in:
parent
8d2f43b6c5
commit
09259ee5fa
6 changed files with 1337 additions and 1322 deletions
328
src/timeline.rs
Normal file
328
src/timeline.rs
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use matrix_sdk::{
|
||||
Room,
|
||||
ruma::{OwnedEventId, OwnedUserId, events::room::message::MessageType},
|
||||
};
|
||||
|
||||
use crate::types::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
|
||||
}
|
||||
|
||||
/// Shorten an event id for prompt display: `$abc123def456...` -> `$abc123de`.
|
||||
pub fn short_event_id(id: &OwnedEventId) -> String {
|
||||
let s = id.as_str();
|
||||
let prefix: String = s.chars().take(9).collect();
|
||||
if s.len() > 9 {
|
||||
format!("{prefix}…")
|
||||
} else {
|
||||
prefix
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Render one timeline item into the prompt.
|
||||
/// Messages: `[ts] $eid... [(you) ]@user: body [read by: ...]`
|
||||
/// Reactions: `[ts] [(you) ]@user reacted to $eid... with KEY`
|
||||
pub fn render_timeline_item(
|
||||
prompt: &mut String,
|
||||
item: &TimelineItem,
|
||||
read_markers: &HashMap<OwnedEventId, Vec<OwnedUserId>>,
|
||||
) {
|
||||
match item {
|
||||
TimelineItem::Message {
|
||||
event_id,
|
||||
sender,
|
||||
body,
|
||||
is_self,
|
||||
ts,
|
||||
..
|
||||
} => {
|
||||
let ts_str = format_ts(*ts);
|
||||
let id = short_event_id(event_id);
|
||||
let prefix = if *is_self { "(you) " } else { "" };
|
||||
let readers_str = match read_markers.get(event_id) {
|
||||
Some(rs) if !rs.is_empty() => {
|
||||
let mut sorted = rs.clone();
|
||||
sorted.sort();
|
||||
let names: Vec<String> = sorted.iter().map(|u| u.to_string()).collect();
|
||||
format!(" [read by: {}]", names.join(", "))
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
writeln!(
|
||||
prompt,
|
||||
"[{ts_str}] {id} {prefix}{sender}: {body}{readers_str}"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
TimelineItem::Reaction {
|
||||
sender,
|
||||
target_event_id,
|
||||
key,
|
||||
is_self,
|
||||
ts,
|
||||
} => {
|
||||
let ts_str = format_ts(*ts);
|
||||
let id = short_event_id(target_event_id);
|
||||
let prefix = if *is_self { "(you) " } else { "" };
|
||||
writeln!(
|
||||
prompt,
|
||||
"[{ts_str}] {prefix}{sender} reacted to {id} with {key}"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
let mut messages: Vec<TimelineItem> = Vec::new();
|
||||
let mut reactions: Vec<TimelineItem> = Vec::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),
|
||||
) => {
|
||||
if messages.len() >= limit {
|
||||
continue;
|
||||
}
|
||||
let MessageType::Text(text) = &orig.content.msgtype else {
|
||||
continue;
|
||||
};
|
||||
let ts = ts_secs_from(orig.origin_server_ts.0);
|
||||
let in_reply_to = match &orig.content.relates_to {
|
||||
Some(matrix_sdk::ruma::events::room::message::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,
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue