mirror of
https://framagit.org/veretcle/oolatoocs.git
synced 2025-12-06 06:43:15 +01:00
344 lines
12 KiB
Rust
344 lines
12 KiB
Rust
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<BskyAgent, Box<dyn Error>> {
|
||
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<String>,
|
||
embed: Option<atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>>,
|
||
reply_to: &Option<BskyReply>,
|
||
) -> Result<RecordData, Box<dyn Error>> {
|
||
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<atrium_api::com::atproto::repo::get_record::OutputData>,
|
||
Box<dyn Error>,
|
||
> {
|
||
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<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error>> {
|
||
// 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<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error + Send + Sync>> {
|
||
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<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error + Send + Sync>> {
|
||
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<Output, Box<dyn Error + Send + Sync>> {
|
||
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);
|
||
}
|
||
}
|