/ src / matrix / commands / aoc / day.rs
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  }