solve_notifications.rs
1 use std::{sync::Arc, time::Duration}; 2 3 use chrono::{DateTime, TimeZone, Utc}; 4 use matrix_sdk::{Room, RoomState}; 5 use tracing::{error, trace, warn}; 6 7 use crate::{ 8 Context, 9 aoc::{ 10 day::AocDay, 11 models::{PrivateLeaderboard, PrivateLeaderboardMember, PrivateLeaderboardMembers}, 12 }, 13 matrix::utils::html_notice, 14 utils::{ 15 datetime::{DateTimeExt, now}, 16 fmt::{fmt_rank, fmt_timedelta}, 17 }, 18 }; 19 20 pub async fn start(context: Arc<Context>) -> ! { 21 let mut year = AocDay::most_recent().year; 22 let mut leaderboard = context 23 .aoc_client 24 .get_private_leaderboard_cached(year) 25 .await 26 .map(|(lb, _)| lb); 27 28 loop { 29 if let Err(err) = trigger(&context, &mut year, &mut leaderboard).await { 30 error!("Failed to check for new puzzle solves: {err}"); 31 } 32 tokio::time::sleep(Duration::from_secs(10)).await; 33 } 34 } 35 36 async fn trigger( 37 context: &Context, 38 year: &mut i32, 39 leaderboard: &mut Option<PrivateLeaderboard>, 40 ) -> anyhow::Result<()> { 41 let room = &context.room; 42 if room.state() != RoomState::Joined { 43 warn!("not a member of target room {}", room.room_id()); 44 room.join().await?; 45 } 46 47 let current_year = AocDay::most_recent().year; 48 if current_year != *year { 49 *leaderboard = None; 50 *year = current_year; 51 } 52 53 trace!(year, "checking for new puzzle solves"); 54 55 let new_leaderboard = context.aoc_client.get_private_leaderboard(*year).await?.0; 56 57 send_notifications( 58 room, 59 context, 60 *year, 61 leaderboard 62 .as_ref() 63 .map(|l| &l.members) 64 .unwrap_or(&Default::default()), 65 &new_leaderboard.members, 66 ) 67 .await?; 68 69 *leaderboard = Some(new_leaderboard); 70 71 Ok(()) 72 } 73 74 async fn send_notifications( 75 room: &Room, 76 context: &Context, 77 year: i32, 78 old_leaderboard: &PrivateLeaderboardMembers, 79 new_leaderboard: &PrivateLeaderboardMembers, 80 ) -> anyhow::Result<()> { 81 let mut notifications = Vec::new(); 82 for (id, member) in new_leaderboard { 83 let Some(old_member) = old_leaderboard.get(id) else { 84 continue; 85 }; 86 87 for (&day, completion) in &member.completion_day_level { 88 let old_completion = old_member.completion_day_level.get(&day); 89 90 if old_completion.is_none() { 91 let rank = new_leaderboard 92 .values() 93 .filter(|m| { 94 m.completion_day_level 95 .get(&day) 96 .is_some_and(|c| c.fst.get_star_ts <= completion.fst.get_star_ts) 97 }) 98 .count(); 99 notifications.push(Notification { 100 member, 101 part2: false, 102 day: AocDay { year, day }, 103 ts: completion.fst.get_star_ts, 104 rank, 105 }); 106 } 107 108 if let Some(part2) = completion 109 .snd 110 .as_ref() 111 .filter(|_| old_completion.is_none_or(|oc| oc.snd.is_none())) 112 { 113 let rank = new_leaderboard 114 .values() 115 .filter(|m| { 116 m.completion_day_level 117 .get(&day) 118 .and_then(|c| c.snd.as_ref()) 119 .is_some_and(|c| c.get_star_ts <= part2.get_star_ts) 120 }) 121 .count(); 122 notifications.push(Notification { 123 member, 124 part2: true, 125 day: AocDay { year, day }, 126 ts: part2.get_star_ts, 127 rank, 128 }); 129 } 130 } 131 } 132 133 let now = now(); 134 notifications.retain(|n| now <= n.ts + Duration::from_secs(24 * 3600)); 135 notifications.sort_unstable_by_key(|n| n.ts); 136 137 trace!(?notifications, "sending puzzle solve notifications"); 138 for notification in notifications { 139 room.send(html_notice(notification.to_string(context))) 140 .await?; 141 } 142 143 Ok(()) 144 } 145 146 #[derive(Debug, Clone, Copy)] 147 struct Notification<'a> { 148 member: &'a PrivateLeaderboardMember, 149 part2: bool, 150 day: AocDay, 151 ts: DateTime<Utc>, 152 rank: usize, 153 } 154 155 impl Notification<'_> { 156 fn to_string(self, context: &Context) -> String { 157 let Self { 158 member, 159 part2, 160 day, 161 ts, 162 rank, 163 } = self; 164 165 let matrix = context 166 .users 167 .by_aoc 168 .get(&member.id) 169 .and_then(|m| m.matrix.as_deref()); 170 171 let part = if part2 { 172 "<span data-mx-color=\"#ffff66\">part two</span>" 173 } else { 174 "<span data-mx-color=\"#9999cc\">part one</span>" 175 }; 176 177 let start = if part2 { 178 member 179 .completion_day_level 180 .get(&day.day) 181 .unwrap() 182 .fst 183 .get_star_ts 184 } else { 185 day.unlock_datetime() 186 }; 187 let delta = fmt_timedelta(ts - start); 188 189 let url = day.url(); 190 let AocDay { year, day } = day; 191 let ts = context 192 .config 193 .local_timezone 194 .from_utc_datetime(&ts.naive_utc()) 195 .format_ymd_hms_z(); 196 197 let name = member.matrix_mention_or_display_name_html(matrix); 198 199 let rank = fmt_rank(rank); 200 201 let link_prefix = &context.config.matrix.link_prefix; 202 format!( 203 "{name} has solved <b>{part}</b> of <a href=\"{link_prefix}{url}\"><b>AoC {year} Day \ 204 {day}</b></a> at {ts} ({rank}, {delta})" 205 ) 206 } 207 }