/ distodon / src / main.rs
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  }