diff --git a/src/main.rs b/src/main.rs index d36ef89..b662f42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,10 +10,11 @@ use matrix_sdk::{ authentication::matrix::MatrixSession, config::SyncSettings, ruma::{ - OwnedEventId, OwnedRoomId, OwnedUserId, + OwnedEventId, OwnedRoomId, OwnedUserId, UserId, api::client::filter::FilterDefinition, - events::room::message::{ - MessageType, OriginalSyncRoomMessageEvent, RoomMessageEventContent, + events::room::{ + member::StrippedRoomMemberEvent, + message::{MessageType, OriginalSyncRoomMessageEvent, RoomMessageEventContent}, }, }, }; @@ -208,11 +209,47 @@ async fn sync( } }); + client.add_event_handler(on_stripped_state_member); + client.sync(sync_settings).await?; bail!("sync loop exited unexpectedly") } +async fn on_stripped_state_member(event: StrippedRoomMemberEvent, client: Client, room: Room) { + let Some(my_id) = client.user_id() else { + return; + }; + if event.state_key != my_id { + return; + } + let room_id = room.room_id().to_owned(); + tokio::spawn(async move { + tracing::info!(room = %room_id, "auto-joining invite"); + let mut delay = 2u64; + loop { + match room.join().await { + Ok(()) => { + tracing::info!(room = %room_id, "joined"); + if let Err(e) = ensure_room_notes(&room).await { + tracing::warn!(room = %room_id, "failed to write room notes: {e}"); + } + return; + } + Err(e) => { + tracing::warn!(room = %room_id, "join failed, retry in {delay}s: {e}"); + tokio::time::sleep(std::time::Duration::from_secs(delay)).await; + delay = (delay * 2).min(300); + if delay >= 300 { + tracing::error!(room = %room_id, "giving up on auto-join"); + return; + } + } + } + } + }); +} + async fn persist_sync_token(session_file: &Path, sync_token: String) -> anyhow::Result<()> { let data = fs::read_to_string(session_file).await?; let mut session: PersistedSession = serde_json::from_str(&data)?; @@ -234,8 +271,10 @@ async fn on_room_message( }; let room_id = room.room_id().to_owned(); - let mut state = state.lock().await; - let is_self = event.sender == state.own_user_id; + let is_self = { + let state = state.lock().await; + event.sender == state.own_user_id + }; tracing::info!( room = %room_id, @@ -245,9 +284,102 @@ async fn on_room_message( text_content.body ); - if !is_self && !state.pending_rooms.contains(&room_id) { - state.pending_rooms.push(room_id); + if let Err(e) = ensure_room_notes(&room).await { + tracing::warn!(room = %room_id, "failed to ensure room notes: {e}"); } + if !is_self { + if let Err(e) = ensure_person_notes(&room, &event.sender).await { + tracing::warn!(sender = %event.sender, "failed to ensure person notes: {e}"); + } + } + + if !is_self { + let mut state = state.lock().await; + if !state.pending_rooms.contains(&room_id) { + state.pending_rooms.push(room_id); + } + } +} + +/// Create state/rooms//notes.md if it doesn't exist. +async fn ensure_room_notes(room: &Room) -> anyhow::Result<()> { + let room_id = room.room_id(); + let dir = paths::state_dir().join("rooms").join(room_id.as_str()); + let file = dir.join("notes.md"); + if file.exists() { + return Ok(()); + } + fs::create_dir_all(&dir).await?; + + let display_name = room + .display_name() + .await + .map(|n| n.to_string()) + .unwrap_or_else(|_| room_id.to_string()); + let now = chrono_now(); + + let body = format!("# {room_id}\n\nDisplay name: {display_name}\n\nFirst joined: {now}\n",); + fs::write(&file, body).await?; + tracing::info!(room = %room_id, "created room notes"); + Ok(()) +} + +/// Create state/people//notes.md if it doesn't exist. +/// Pre-fill with display name (from this room's member info) and the room as +/// "first met in". +async fn ensure_person_notes(room: &Room, user_id: &UserId) -> anyhow::Result<()> { + let dir = paths::state_dir().join("people").join(user_id.as_str()); + let file = dir.join("notes.md"); + if file.exists() { + return Ok(()); + } + fs::create_dir_all(&dir).await?; + + let display_name = room + .get_member_no_sync(user_id) + .await + .ok() + .flatten() + .and_then(|m| m.display_name().map(ToOwned::to_owned)) + .unwrap_or_default(); + let display_line = if display_name.is_empty() { + String::new() + } else { + format!("Display name: {display_name}\n") + }; + let room_id = room.room_id(); + let now = chrono_now(); + + let body = format!("# {user_id}\n\n{display_line}First met in: {room_id} on {now}\n",); + fs::write(&file, body).await?; + tracing::info!(user = %user_id, "created person notes"); + Ok(()) +} + +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); + // Simple ISO-ish without pulling chrono. YYYY-MM-DD only is fine for notes. + 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) } async fn process_loop(state: Arc>, client: Client) {