use crate::{config::BlueskyConfig, utils::convert_aspect_ratio, OolatoocsError}; use atrium_api::{ app::bsky::feed::post::RecordData, com::atproto::repo::upload_blob::Output, types::string::Datetime, types::string::Language, types::string::RecordKey, }; use bsky_sdk::{ agent::config::{Config, FileStore}, rich_text::RichText, BskyAgent, }; use futures::{stream, StreamExt}; use image::ImageReader; use log::{debug, error, warn}; use megalodon::entities::{ attachment::{Attachment, AttachmentType}, card::Card, }; use regex::Regex; use std::{error::Error, fs::exists, io::Cursor}; use webp::*; /// Intermediary struct to deal with replies more easily #[derive(Debug)] pub struct BskyReply { pub record_uri: String, pub root_record_uri: String, } pub async fn get_session(config: &BlueskyConfig) -> Result> { if exists(&config.config_path)? { let bluesky = BskyAgent::builder() .config(Config::load(&FileStore::new(&config.config_path)).await?) .build() .await?; if bluesky.api.com.atproto.server.get_session().await.is_ok() { bluesky .to_config() .await .save(&FileStore::new(&config.config_path)) .await?; return Ok(bluesky); } } let bluesky = BskyAgent::builder().build().await?; bluesky.login(&config.handle, &config.password).await?; bluesky .to_config() .await .save(&FileStore::new(&config.config_path)) .await?; Ok(bluesky) } pub async fn build_post_record( config: &BlueskyConfig, text: &str, language: &Option, embed: Option>, reply_to: &Option, ) -> Result> { let mut rt = RichText::new_with_detect_facets(text).await?; let insert_chars = "…"; let re = Regex::new(r#"(https?://)(www\.)?(\S{1,26})(\S*)"#).unwrap(); while let Some(found) = re.captures(&rt.text.clone()) { if let Some(group) = found.get(4) { if !group.is_empty() { rt.insert(group.start(), insert_chars); rt.delete( group.start() + insert_chars.len(), group.start() + insert_chars.len() + group.len(), ); } } if let Some(group) = found.get(1) { let www: usize = found.get(2).map_or(0, |x| x.len()); rt.delete(group.start(), group.start() + www + group.len()); } } let langs = language.clone().map(|s| vec![Language::new(s).unwrap()]); let reply = if let Some(x) = reply_to { let root_record = get_record(&config.handle, &rkey(&x.root_record_uri)).await?; let parent_record = get_record(&config.handle, &rkey(&x.record_uri)).await?; Some( atrium_api::app::bsky::feed::post::ReplyRefData { parent: atrium_api::com::atproto::repo::strong_ref::MainData { cid: parent_record.data.cid.unwrap(), uri: parent_record.data.uri.to_owned(), } .into(), root: atrium_api::com::atproto::repo::strong_ref::MainData { cid: root_record.data.cid.unwrap(), uri: root_record.data.uri.to_owned(), } .into(), } .into(), ) } else { None }; Ok(RecordData { created_at: Datetime::now(), embed, entities: None, facets: rt.facets, labels: None, langs, reply, tags: None, text: rt.text, }) } async fn get_record( config: &str, rkey: &str, ) -> Result< atrium_api::types::Object, Box, > { let bsky = BskyAgent::builder().build().await?; let record = bsky .api .com .atproto .repo .get_record( atrium_api::com::atproto::repo::get_record::ParametersData { cid: None, collection: atrium_api::types::string::Nsid::new("app.bsky.feed.post".to_string())?, repo: atrium_api::types::string::Handle::new(config.to_string())?.into(), rkey: RecordKey::new(rkey.to_string())?, } .into(), ) .await?; Ok(record) } /// Generate an quote embed record /// it is encapsulated in Option to prevent this function from failing pub async fn generate_quote_records( config: &BlueskyConfig, quote_id: &str, ) -> Result> { // if we can’t match the quote_id, simply return None let quote_record = get_record(&config.handle, &rkey(quote_id)).await?; Ok( atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedRecordMain(Box::new( atrium_api::app::bsky::embed::record::MainData { record: atrium_api::com::atproto::repo::strong_ref::MainData { cid: quote_record.data.cid.unwrap(), uri: quote_record.data.uri.to_owned(), } .into(), } .into(), )), ) } /// Generate an embed webcard record into Bsky /// If the preview image does not exist or fails to upload, it is simply ignored pub async fn generate_webcard_records( bsky: &BskyAgent, card: &Card, ) -> Result> { let blob = match &card.image { Some(url) => upload_media(true, bsky, url).await?.blob.clone().into(), None => None, }; let record_card = atrium_api::app::bsky::embed::external::ExternalData { description: card.description.clone(), thumb: blob, title: card.title.clone(), uri: card.url.clone(), }; Ok( atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedExternalMain(Box::new( atrium_api::app::bsky::embed::external::MainData { external: record_card.into(), } .into(), )), ) } /// Generate an array of Bsky media records /// As Bsky does not support multiple video in a record or mix of video and images, video has the /// highest priority pub async fn generate_media_records( bsky: &BskyAgent, media_attach: &[Attachment], ) -> Result> { let image_media_attach: Vec<_> = media_attach .iter() .filter(|x| x.r#type == AttachmentType::Image) .cloned() .collect(); let video_media_attach: Vec<_> = media_attach .iter() .filter(|x| x.r#type == AttachmentType::Video || x.r#type == AttachmentType::Gifv) .cloned() .collect(); // Bsky only tasks 1 video per post, so we’ll try to treat that first and exit if !video_media_attach.is_empty() { // treat only the very first video, ignore the rest let media = &video_media_attach[0]; let blob = upload_media(false, bsky, &media.url).await?; return Ok( atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedVideoMain(Box::new( atrium_api::app::bsky::embed::video::MainData { alt: media.description.clone(), aspect_ratio: convert_aspect_ratio( &media.meta.as_ref().and_then(|m| m.original.clone()), ), captions: None, video: blob.data.blob, } .into(), )), ); } // It wasn’t a video, then it’s an image or a gallery of 4 images let mut stream = stream::iter(image_media_attach) .map(|media| { let bsky = bsky.clone(); tokio::task::spawn(async move { debug!("Treating media {}", &media.url); upload_media(true, &bsky, &media.url).await.map(|i| { atrium_api::app::bsky::embed::images::ImageData { alt: media .description .clone() .map_or("".to_string(), |v| v.to_owned()), aspect_ratio: convert_aspect_ratio( &media.meta.as_ref().and_then(|m| m.original.clone()), ), image: i.data.blob, } }) }) }) .buffered(4); let mut images = Vec::new(); while let Some(result) = stream.next().await { match result { Ok(Ok(v)) => images.push(v.into()), Ok(Err(e)) => warn!("Cannot treat a specific media: {}", e), Err(e) => error!("Something went wrong when joining main thread: {}", e), } } if !images.is_empty() { return Ok( atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedImagesMain(Box::new( atrium_api::app::bsky::embed::images::MainData { images }.into(), )), ); } Err(OolatoocsError::new("Cannot embed media").into()) } async fn upload_media( is_image: bool, bsky: &BskyAgent, u: &str, ) -> Result> { let dl = reqwest::get(u).await?; let content_length = dl.content_length().ok_or("Content length unavailable")?; let bytes = if content_length <= 1_000_000 || !is_image { dl.bytes().await?.as_ref().to_vec() } else { // this is an image and it’s over 1Mb long debug!("Img file too large: {}", content_length); // defaults to 95% quality for WebP compression let mut default_quality = 95f32; let img = ImageReader::new(Cursor::new(dl.bytes().await?)) .with_guessed_format()? .decode()?; let encoder: Encoder = Encoder::from_image(&img)?; let mut webp: WebPMemory = encoder.encode(default_quality); while webp.len() > 1_000_000 { debug!("Img file too large at {}%, reducing…", default_quality); default_quality -= 5.0; webp = encoder.encode(default_quality); } webp.to_vec() }; let record = bsky.api.com.atproto.repo.upload_blob(bytes).await?; Ok(record) } fn rkey(record_id: &str) -> String { record_id.split('/').nth(4).unwrap().to_string() } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_build_post_record() { let text = "@factornews@piaille.fr Retrouvez-nous ici https://www.nintendojo.fr/articles/editos/le-mod-renovation-de-8bitdo-pour-manette-n64 et là https://www.nintendojo.fr/articles/analyses/vite-vu/vite-vu-morbid-the-lords-of-ire et un lien très court http://vsl.ie/TaMere et un autre https://p.nintendojo.fr/w/kV3CBbKKt1nPEChHhZiNve + http://www.xxx.com + https://www.youtube.com/watch?v=dQw4w9WgXcQ&pp=ygUJcmljayByb2xs"; let expected_text = "@factornews@piaille.fr Retrouvez-nous ici nintendojo.fr/articles/edi… et là nintendojo.fr/articles/ana… et un lien très court vsl.ie/TaMere et un autre p.nintendojo.fr/w/kV3CBbKK… + xxx.com + youtube.com/watch?v=dQw4w9…"; let bsky_conf = BlueskyConfig { handle: "tamerelol.bsky.social".to_string(), password: "dtc".to_string(), config_path: "nope".to_string(), }; let created_record_data = build_post_record(&bsky_conf, text, &None, None, &None) .await .unwrap(); assert_eq!(expected_text, &created_record_data.text); } }