day.rs
1 use std::fmt::Write; 2 3 use chrono::TimeZone; 4 use matrix_sdk::{ 5 Room, 6 ruma::{api::client::error::ErrorKind, events::room::message::OriginalRoomMessageEvent}, 7 }; 8 9 use crate::{ 10 aoc::{ 11 client::Parts, 12 day::{AocDay, number_of_days}, 13 models::PrivateLeaderboardMember, 14 }, 15 context::Context, 16 matrix::{ 17 commands::{ 18 CommandError, CommandResult, 19 parser::ParsedCommand, 20 parser_ext::{ARG_DAY, ParserExt}, 21 }, 22 utils::{RoomExt, error_message, html_message}, 23 }, 24 utils::{ 25 datetime::DateTimeExt, 26 fmt::{fmt_rank, fmt_timedelta}, 27 }, 28 }; 29 30 const ARG_PART: &str = "p"; 31 32 pub async fn invoke( 33 event: &OriginalRoomMessageEvent, 34 room: &Room, 35 context: &Context, 36 mut cmd: ParsedCommand<'_>, 37 ) -> CommandResult<()> { 38 let day = cmd.parse_day_required(AocDay::current().map(|d| d.day))?; 39 let most_recent_year = AocDay::most_recent().year; 40 let year = cmd.parse_year_default_most_recent(most_recent_year)?; 41 42 let number_of_days = number_of_days(year); 43 if !(1..=number_of_days).contains(&day) { 44 return Err(CommandError::ArgParse { 45 arg: ARG_DAY, 46 allowed_values: Some(format!("1, ..., {number_of_days}").into()), 47 }); 48 } 49 50 let parts = match cmd.get_from_kwargs_or_args(ARG_PART) { 51 Some("1") => Parts::P1, 52 Some("2") => Parts::P2, 53 Some("both") | None => Parts::Both, 54 Some(_) => { 55 return Err(CommandError::ArgParse { 56 arg: ARG_PART, 57 allowed_values: Some("1, 2, both".into()), 58 }); 59 } 60 }; 61 62 let rows = cmd 63 .parse_rows()? 64 .unwrap_or(context.config.aoc.leaderboard_rows); 65 let offset = cmd.parse_offset()?; 66 67 let (leaderboard, last_update) = match context 68 .aoc_client 69 .get_daily_private_leaderboard(year, day, parts) 70 .await 71 { 72 Ok(resp) => resp, 73 Err(err) => match err.downcast::<reqwest::Error>() { 74 Ok(err) => { 75 if let Some(status) = err.status() { 76 room.reply_to( 77 event, 78 error_message(format!( 79 "Failed to fetch private leaderboard for {year} ({status})" 80 )), 81 ) 82 .await?; 83 return Ok(()); 84 } else { 85 return Err(err.into()); 86 } 87 } 88 Err(err) => return Err(err.into()), 89 }, 90 }; 91 let last_update = context 92 .config 93 .local_timezone 94 .from_utc_datetime(&last_update.naive_utc()) 95 .format_ymd_hms_z(); 96 97 let mut members = leaderboard.members.into_values().collect::<Vec<_>>(); 98 members.sort_unstable(); 99 100 let parts_title = match parts { 101 Parts::P1 => "/1", 102 Parts::P2 => "/2", 103 Parts::Both => "", 104 }; 105 let mut leaderboard = format!( 106 r#" 107 <h3>Private Leaderboard (Advent of Code {year}/{day:02}{parts_title})</h3> 108 <table> 109 <tr> <th>Rank</th> <th>Local Score</th> <th>Stars</th> <th>Completion</th> <th>AoC Name</th> <th>Matrix User</th> <th>Repository</th> </tr> 110 "# 111 ); 112 113 let unlock = AocDay { year, day }.unlock_datetime(); 114 115 let mut last_score = u32::MAX; 116 let mut rank = 0; 117 for (rank, member) in members 118 .into_iter() 119 .enumerate() 120 .map(|(i, member)| { 121 if member.local_score != last_score { 122 last_score = member.local_score; 123 rank = i + 1; 124 } 125 (rank, member) 126 }) 127 .filter(|(_, m)| m.stars > 0) 128 .skip(offset) 129 .take(rows) 130 { 131 let PrivateLeaderboardMember { 132 local_score, stars, .. 133 } = member; 134 135 let name = member.display_name(); 136 137 let matrix_name = context 138 .users 139 .by_aoc 140 .get(&member.id) 141 .and_then(|u| u.matrix.as_ref()) 142 .map(|m| m.matrix_to_uri().to_string()) 143 .unwrap_or_default(); 144 145 let repo = context 146 .users 147 .by_aoc 148 .get(&member.id) 149 .and_then(|u| u.repo.as_deref()) 150 .unwrap_or_default(); 151 let repo_title = context 152 .config 153 .aoc 154 .repo_rules 155 .match_and_replace(repo) 156 .map(|m| m.replacement); 157 let repo_title = repo_title.as_deref().unwrap_or(repo); 158 159 let (m, m_) = if rank <= 3 { 160 ("<b>", "</b>") 161 } else { 162 Default::default() 163 }; 164 165 let rank = fmt_rank(rank); 166 167 let completion = context 168 .config 169 .local_timezone 170 .from_utc_datetime(&member.last_star_ts.naive_utc()) 171 .format_ymd_hms(); 172 173 let start = match parts { 174 Parts::P1 | Parts::Both => unlock, 175 Parts::P2 => { 176 member 177 .completion_day_level 178 .get(&day) 179 .unwrap() 180 .fst 181 .get_star_ts 182 } 183 }; 184 let delta = fmt_timedelta(member.last_star_ts - start); 185 186 let link_prefix = &context.config.matrix.link_prefix; 187 write!( 188 &mut leaderboard, 189 r#" 190 <tr> 191 <td>{m}{rank}{m_}</td> 192 <td>{m}{local_score}{m_}</td> 193 <td>{m}{stars}{m_}</td> 194 <td>{completion}({m}{delta}{m_})</td> 195 <td>{m}{name}{m_}</td> 196 <td>{matrix_name}</td> 197 <td>{m}<a href="{link_prefix}{repo}">{repo_title}</a>{m_}</td> 198 </tr> 199 "# 200 ) 201 .unwrap(); 202 } 203 204 write!( 205 &mut leaderboard, 206 r#" 207 </table> 208 <sup>Last update: {last_update}</sup> 209 "# 210 ) 211 .unwrap(); 212 213 if let Err(err) = room.reply_to(event, html_message(leaderboard)).await { 214 if err 215 .as_client_api_error() 216 .and_then(|err| err.error_kind()) 217 .is_some_and(|kind| matches!(kind, ErrorKind::TooLarge)) 218 { 219 room.reply_to( 220 event, 221 error_message( 222 "The requested leaderboard slice would be too large to fit in a matrix \ 223 message. Try to reduce the number of rows.", 224 ), 225 ) 226 .await?; 227 return Ok(()); 228 } else { 229 return Err(err.into()); 230 } 231 } 232 233 Ok(()) 234 }