Files
oolatoocs/src/bsky.rs
2025-12-01 16:39:37 +01:00

344 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 cant 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 well 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 wasnt a video, then its 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 its 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);
}
}