main.rs
1 use std::{ 2 collections::hash_map::DefaultHasher, 3 fs, 4 hash::{Hash, Hasher}, 5 path::{Path, PathBuf}, 6 thread::sleep, 7 time::Duration, 8 }; 9 10 use anyhow::{ensure, Context}; 11 use itertools::Itertools; 12 use log::{debug, error, info}; 13 use regex::Regex; 14 use url::Url; 15 16 use self::{ 17 discord::{execute_webhook, WebhookPayload}, 18 mastodon::{fetch_posts, Id, Post}, 19 }; 20 use crate::discord::{check_webhook, Embed, EmbedAuthor, EmbedImage}; 21 22 mod config; 23 mod discord; 24 mod http; 25 mod mastodon; 26 27 #[derive(Hash)] 28 struct Link { 29 mastodon_url: Url, 30 account_id: Id, 31 webhook_url: Url, 32 path: PathBuf, 33 last_id: Id, 34 } 35 36 impl Link { 37 fn new( 38 mastodon_url: Url, 39 account_id: Id, 40 webhook_url: Url, 41 path: PathBuf, 42 ) -> anyhow::Result<Self> { 43 let last_id = Id(if path.try_exists()? { 44 fs::read_to_string(&path)?.trim().parse()? 45 } else { 46 0 47 }); 48 Ok(Self { 49 mastodon_url, 50 account_id, 51 webhook_url, 52 path, 53 last_id, 54 }) 55 } 56 57 fn fetch_new_posts(&self, last_id: &Id) -> anyhow::Result<Vec<Post>> { 58 fetch_posts(&self.mastodon_url, &self.account_id, last_id, 20, true) 59 .context("fetching posts") 60 } 61 62 fn run(&mut self, chunk_size: usize) -> anyhow::Result<()> { 63 debug!("run for {:?} on {}", self.account_id, self.mastodon_url); 64 let title_regex = Regex::new("<.*?>")?; 65 for chunk in &self 66 .fetch_new_posts(&self.last_id)? 67 .iter() 68 .filter_map(|post| post.media_attachments.first().map(|ma| (post, ma))) 69 .filter(|(_, media)| matches!(media.type_, mastodon::AttachmentType::Image)) 70 .rev() 71 .chunks(chunk_size) 72 { 73 let posts = chunk.collect::<Vec<_>>(); 74 if posts.is_empty() { 75 continue; 76 } 77 78 let mut embeds = Vec::with_capacity(posts.len()); 79 for &(post, media) in &posts { 80 debug!("got new post: {post:?} {media:?}"); 81 embeds.push(Embed { 82 title: title_regex.replace_all(&post.content, "").into_owned(), 83 timestamp: &post.created_at, 84 image: EmbedImage { url: &media.url }, 85 url: &post.url, 86 author: EmbedAuthor { 87 name: &post.account.display_name, 88 url: &post.account.url, 89 icon_url: &post.account.avatar, 90 }, 91 color: 0x595aff, 92 }); 93 self.last_id = self.last_id.max(post.id); 94 } 95 96 execute_webhook( 97 self.webhook_url.clone(), 98 &WebhookPayload { 99 embeds: &embeds, 100 username: "Mastodon", 101 avatar_url: "https://raw.githubusercontent.com/mastodon/joinmastodon/c6fcdf841804349a95f7271c4e0f743974854ff2/public/app-icon.png", 102 }, 103 )?; 104 } 105 Ok(()) 106 } 107 } 108 109 fn main() -> anyhow::Result<()> { 110 pretty_env_logger::init(); 111 112 info!("loading config"); 113 let config = config::load_config()?; 114 debug!("{config:?}"); 115 116 ensure!( 117 (1..=10).contains(&config.chunk_size), 118 "chunk_size cannot be 0 or greater than 10" 119 ); 120 121 let path = Path::new("data"); 122 if !path.is_dir() { 123 fs::create_dir(path)?; 124 } 125 126 let mut links = config 127 .links 128 .into_iter() 129 .map(|link| { 130 let account = mastodon::lookup_account(&link.mastodon_server_url, &link.mastodon_user) 131 .context("looking up account")?; 132 let mut s = DefaultHasher::new(); 133 ( 134 &link.mastodon_server_url, 135 &link.mastodon_user, 136 &link.webhook_url, 137 ) 138 .hash(&mut s); 139 let path = path.join(s.finish().to_string()); 140 check_webhook(link.webhook_url.clone()).context("checking webhook")?; 141 Link::new(link.mastodon_server_url, account.id, link.webhook_url, path) 142 }) 143 .collect::<anyhow::Result<Vec<_>>>()?; 144 info!("got {} links", links.len()); 145 146 loop { 147 for link in &mut links { 148 if let Err(err) = link.run(config.chunk_size) { 149 error!("{err:#}"); 150 } 151 if let Err(err) = 152 fs::write(&link.path, link.last_id.0.to_string()).context("updating last_id") 153 { 154 error!("{err:#}"); 155 } 156 } 157 sleep(Duration::from_secs(config.interval)); 158 } 159 }