/ src / tasks / garygrady_posts.rs
garygrady_posts.rs
  1  use std::{
  2      sync::{Arc, LazyLock},
  3      time::Duration,
  4  };
  5  
  6  use matrix_sdk::{
  7      RoomState,
  8      ruma::events::room::message::{
  9          FormattedBody, ImageMessageEventContent, MessageType, RoomMessageEventContent,
 10      },
 11  };
 12  use mime_guess::MimeGuess;
 13  use regex::Regex;
 14  use tracing::{error, trace, warn};
 15  
 16  use crate::{
 17      Context,
 18      mastodon::{self, AttachmentType},
 19      utils::datetime::now,
 20  };
 21  
 22  const LAST_ID_STORE_KEY: &[u8] = b"garygrady_last_id";
 23  
 24  static CONTENT_REGEX: LazyLock<Regex> =
 25      LazyLock::new(|| Regex::new(r"((?i)advent\s*of\s*code)|AoC").unwrap());
 26  
 27  pub async fn start(context: Arc<Context>) -> ! {
 28      let mut last_id = mastodon::Id(
 29          context
 30              .store
 31              .get::<u64>(LAST_ID_STORE_KEY)
 32              .await
 33              .ok()
 34              .flatten()
 35              .unwrap_or(0),
 36      );
 37  
 38      loop {
 39          if let Err(err) = trigger(&context, &mut last_id).await {
 40              error!("Failed to check for member join/leave events: {err}");
 41          }
 42          tokio::time::sleep(Duration::from_secs(context.config.garygrady.interval)).await;
 43      }
 44  }
 45  
 46  async fn trigger(context: &Context, last_id: &mut mastodon::Id) -> anyhow::Result<()> {
 47      let room = &context.room;
 48      if room.state() != RoomState::Joined {
 49          warn!("not a member of target room {}", room.room_id());
 50          room.join().await?;
 51      }
 52  
 53      trace!("checking for new garygrady posts");
 54  
 55      let now = now();
 56      let max_age = Duration::from_secs(context.config.garygrady.max_age);
 57      let not_before = now - max_age;
 58  
 59      loop {
 60          let posts = mastodon::fetch_original_media_posts(
 61              &context.garygrady.server,
 62              context.garygrady.user_id,
 63              *last_id,
 64              20,
 65          )
 66          .await?;
 67          if posts.is_empty() {
 68              break;
 69          }
 70  
 71          for post in posts.into_iter().rev() {
 72              *last_id = post.id.max(*last_id);
 73  
 74              if !CONTENT_REGEX.is_match(&post.content) || post.created_at < not_before {
 75                  continue;
 76              }
 77  
 78              for media in post.media_attachments {
 79                  let AttachmentType::Image = media.r#type else {
 80                      continue;
 81                  };
 82  
 83                  let Some(mime) = MimeGuess::from_path(media.url.path()).first() else {
 84                      warn!(
 85                          "Failed to determine mime type of media attachment at {}",
 86                          media.url
 87                      );
 88                      continue;
 89                  };
 90  
 91                  let filename = media.url.path().rsplit('/').next().unwrap().to_owned();
 92  
 93                  let image = reqwest::Client::new()
 94                      .get(media.url)
 95                      .send()
 96                      .await?
 97                      .error_for_status()?
 98                      .bytes()
 99                      .await?;
100  
101                  let response = room
102                      .client()
103                      .media()
104                      .upload(&mime, image.to_vec(), None)
105                      .await?;
106  
107                  let link_prefix = &context.config.matrix.link_prefix;
108                  let caption = format!(
109                      r#"<a href="{link_prefix}{}">{}</a> (created by <a href="{link_prefix}{}">@{}</a>)"#,
110                      post.url,
111                      remove_html_tags(&post.content),
112                      post.account.url,
113                      post.account.username
114                  );
115  
116                  let mut image_message =
117                      ImageMessageEventContent::plain(caption.clone(), response.content_uri);
118                  image_message.filename = Some(filename);
119                  image_message.formatted = Some(FormattedBody::html(caption));
120  
121                  room.send(RoomMessageEventContent::new(MessageType::Image(
122                      image_message,
123                  )))
124                  .await?;
125              }
126  
127              context
128                  .store
129                  .set::<u64>(LAST_ID_STORE_KEY, &last_id.0)
130                  .await?;
131          }
132      }
133  
134      Ok(())
135  }
136  
137  fn remove_html_tags(html: &str) -> String {
138      static TAG_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"<.*?>"#).unwrap());
139      static BR_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"<br */?>"#).unwrap());
140      TAG_REGEX
141          .replace_all(&BR_REGEX.replace_all(html, " "), "")
142          .into()
143  }