/ src / tasks / solve_notifications.rs
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  }