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 }