/ src / matrix / commands / aoc / leaderboard.rs
leaderboard.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::{day::AocDay, models::PrivateLeaderboardMember},
 11      context::Context,
 12      matrix::{
 13          commands::{CommandResult, parser::ParsedCommand, parser_ext::ParserExt},
 14          utils::{RoomExt, error_message, html_message},
 15      },
 16      utils::{
 17          datetime::DateTimeExt,
 18          fmt::{fmt_option, fmt_rank},
 19      },
 20  };
 21  
 22  pub async fn invoke(
 23      event: &OriginalRoomMessageEvent,
 24      room: &Room,
 25      context: &Context,
 26      mut cmd: ParsedCommand<'_>,
 27  ) -> CommandResult<()> {
 28      let most_recent_year = AocDay::most_recent().year;
 29  
 30      let year = cmd.parse_year_default_most_recent(most_recent_year)?;
 31      let rows = cmd
 32          .parse_rows()?
 33          .unwrap_or(context.config.aoc.leaderboard_rows);
 34      let offset = cmd.parse_offset()?;
 35  
 36      let (leaderboard, last_update) = match context.aoc_client.get_private_leaderboard(year).await {
 37          Ok(resp) => resp,
 38          Err(err) => match err.downcast::<reqwest::Error>() {
 39              Ok(err) => {
 40                  if let Some(status) = err.status() {
 41                      room.reply_to(
 42                          event,
 43                          error_message(format!(
 44                              "Failed to fetch private leaderboard for {year} ({status})"
 45                          )),
 46                      )
 47                      .await?;
 48                      return Ok(());
 49                  } else {
 50                      return Err(err.into());
 51                  }
 52              }
 53              Err(err) => return Err(err.into()),
 54          },
 55      };
 56      let last_update = context
 57          .config
 58          .local_timezone
 59          .from_utc_datetime(&last_update.naive_utc())
 60          .format_ymd_hms_z();
 61  
 62      let mut members = leaderboard.members.into_values().collect::<Vec<_>>();
 63      members.sort_unstable();
 64  
 65      let has_global_scores = members.iter().any(|m| m.global_score.is_some());
 66      let global_score_header = has_global_scores
 67          .then_some("<th>Global Score</th>")
 68          .unwrap_or_default();
 69  
 70      let mut leaderboard = format!(
 71          r#"
 72  <h3>Private Leaderboard (Advent of Code {year})</h3>
 73  <table>
 74  <tr> <th>Rank</th> <th>Local Score</th> {global_score_header} <th>Stars</th> <th>AoC Name</th> <th>Matrix User</th> <th>Repository</th> </tr>
 75  "#
 76      );
 77  
 78      let mut last_score = u32::MAX;
 79      let mut rank = 0;
 80      for (rank, member) in members
 81          .into_iter()
 82          .enumerate()
 83          .map(|(i, member)| {
 84              if member.local_score != last_score {
 85                  last_score = member.local_score;
 86                  rank = i + 1;
 87              }
 88              (rank, member)
 89          })
 90          .skip(offset)
 91          .take(rows)
 92      {
 93          let PrivateLeaderboardMember {
 94              local_score,
 95              global_score,
 96              stars,
 97              ..
 98          } = member;
 99  
100          let name = member.display_name();
101  
102          let matrix_name = context
103              .users
104              .by_aoc
105              .get(&member.id)
106              .and_then(|u| u.matrix.as_ref())
107              .map(|m| m.matrix_to_uri().to_string())
108              .unwrap_or_default();
109  
110          let repo = context
111              .users
112              .by_aoc
113              .get(&member.id)
114              .and_then(|u| u.repo.as_deref())
115              .unwrap_or_default();
116          let repo_title = context
117              .config
118              .aoc
119              .repo_rules
120              .match_and_replace(repo)
121              .map(|m| m.replacement);
122          let repo_title = repo_title.as_deref().unwrap_or(repo);
123  
124          let (m, m_) = if rank <= 3 {
125              ("<b>", "</b>")
126          } else {
127              Default::default()
128          };
129  
130          let rank = fmt_rank(rank);
131  
132          let link_prefix = &context.config.matrix.link_prefix;
133          let global_score = has_global_scores
134              .then(|| format!("<td>{m}{}{m_}</td>", fmt_option(global_score.as_ref())))
135              .unwrap_or_default();
136          write!(
137              &mut leaderboard,
138              r#"
139  <tr>
140      <td>{m}{rank}{m_}</td>
141      <td>{m}{local_score}{m_}</td>
142      {global_score}
143      <td>{m}{stars}{m_}</td>
144      <td>{m}{name}{m_}</td>
145      <td>{matrix_name}</td>
146      <td>{m}<a href="{link_prefix}{repo}">{repo_title}</a>{m_}</td>
147  </tr>
148  "#
149          )
150          .unwrap();
151      }
152  
153      write!(
154          &mut leaderboard,
155          r#"
156  </table>
157  <sup>Last update: {last_update}</sup>
158  "#
159      )
160      .unwrap();
161  
162      if let Err(err) = room.reply_to(event, html_message(leaderboard)).await {
163          if err
164              .as_client_api_error()
165              .and_then(|err| err.error_kind())
166              .is_some_and(|kind| matches!(kind, ErrorKind::TooLarge))
167          {
168              room.reply_to(
169                  event,
170                  error_message(
171                      "The requested leaderboard slice would be too large to fit in a matrix \
172                       message. Try to reduce the number of rows.",
173                  ),
174              )
175              .await?;
176              return Ok(());
177          } else {
178              return Err(err.into());
179          }
180      }
181  
182      Ok(())
183  }