11 Commits

Author SHA1 Message Date
VC
823f80729f Merge branch '13-update-bsky-sdk-dependency' into 'main'
⬆: bsky-sdk v0.1.20 + atrium_api v0.25.4

Closes #13

See merge request veretcle/oolatoocs!32
2025-06-16 06:28:38 +00:00
VC
5969e3a56a ⬆: bsky-sdk v0.1.20 + atrium_api v0.25.4 2025-06-12 15:16:35 +02:00
VC
3ea2478512 fix: count 26 chars per url each time 2025-06-12 14:37:02 +02:00
VC
5606d00da2 Merge branch '10-better-embed-links-for-bsky' into 'main'
: add embed card when available

Closes #10

See merge request veretcle/oolatoocs!29
2025-01-26 08:50:43 +00:00
VC
4cb80b0607 : add embed card when available 2025-01-26 09:33:20 +01:00
VC
bbe14f1f30 Merge branch 'feat_update_dependencies' into 'main'
⬆️: update all dependencies

See merge request veretcle/oolatoocs!28
2025-01-24 14:43:51 +00:00
VC
6fbc011914 ⬆️: update all dependencies 2025-01-24 15:38:46 +01:00
VC
8f23c2459b Merge branch 'feat_megalodon_update' into 'main'
⬆️: megalodon 1.0.0

See merge request veretcle/oolatoocs!27
2025-01-24 14:20:44 +00:00
VC
26805feadb ⬆️: megalodon 1.0.0 2025-01-24 15:12:08 +01:00
VC
3a8fd538fc Merge branch '11-optimize-image-upload' into 'main'
🎨: improve bsky image upload

Closes #11

See merge request veretcle/oolatoocs!26
2025-01-24 13:43:06 +00:00
VC
891f46ec2f 🎨: improve bsky image upload 2025-01-24 14:34:01 +01:00
7 changed files with 1243 additions and 599 deletions

1645
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "oolatoocs"
version = "4.1.1"
version = "4.2.2"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -8,19 +8,20 @@ edition = "2021"
[dependencies]
chrono = "^0.4"
clap = "^4"
env_logger = "^0.10"
env_logger = "^0.11"
futures = "^0.3"
html-escape = "^0.2"
log = "^0.4"
megalodon = "^0.13"
megalodon = "^1.0"
oauth1-request = "^0.6"
regex = "^1.10"
reqwest = { version = "^0.11", features = ["json", "stream", "multipart"] }
rusqlite = { version = "^0.30", features = ["chrono"] }
reqwest = { version = "^0.12", features = ["json", "stream", "multipart"] }
rusqlite = { version = "^0.33", features = ["chrono"] }
serde = { version = "^1.0", features = ["derive"] }
tokio = { version = "^1.33", features = ["rt-multi-thread", "macros"] }
toml = "^0.8"
bsky-sdk = "^0.1"
atrium-api = "^0.24"
atrium-api = { version = "^0.25", features = ["namespace-appbsky"] }
image = "^0.25"
webp = "^0.3"

View File

@@ -1,16 +1,20 @@
use crate::config::BlueskyConfig;
use atrium_api::{
app::bsky::feed::post::RecordData, com::atproto::repo::upload_blob::Output,
types::string::Datetime, types::string::Language,
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};
use megalodon::entities::attachment::{Attachment, AttachmentType};
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::*;
@@ -135,7 +139,7 @@ async fn get_record(
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: rkey.to_string(),
rkey: RecordKey::new(rkey.to_string())?,
}
.into(),
)
@@ -144,7 +148,43 @@ async fn get_record(
Ok(record)
}
// its ugly af but it gets the job done for now
/// Generate an embed card record into Bsky
/// If the preview image does not exist or fails to upload, it is simply ignored
pub async fn generate_embed_records(
bsky: &BskyAgent,
card: &Card,
) -> Option<atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>> {
// uploads the image card, if it fails, simply ignore everything
let blob = if let Some(url) = &card.image {
if let Ok(image_blob) = upload_media(true, bsky, url).await {
Some(image_blob.blob.clone())
} else {
None
}
} else {
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(),
};
Some(atrium_api::types::Union::Refs(
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],
@@ -152,39 +192,66 @@ pub async fn generate_media_records(
let mut embed: Option<
atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>,
> = None;
let mut images = Vec::new();
let mut videos: Vec<atrium_api::app::bsky::embed::video::MainData> = Vec::new();
for media in media_attach.iter() {
match media.r#type {
AttachmentType::Image => {
let blob = upload_media(true, bsky, &media.url).await.unwrap();
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();
images.push(
// 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.unwrap();
embed = Some(atrium_api::types::Union::Refs(
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedVideoMain(Box::new(
atrium_api::app::bsky::embed::video::MainData {
alt: media.description.clone(),
aspect_ratio: None,
captions: None,
video: blob.data.blob,
}
.into(),
)),
));
// returns immediately, we dont want to treat the other medias
return embed;
}
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: None,
image: blob.data.blob,
image: i.data.blob,
}
.into(),
);
}
AttachmentType::Gifv | AttachmentType::Video => {
let blob = upload_media(false, bsky, &media.url).await.unwrap();
})
})
})
.buffered(4);
videos.push(atrium_api::app::bsky::embed::video::MainData {
alt: media.description.clone(),
aspect_ratio: None,
captions: None,
video: blob.data.blob,
});
}
_ => {
error!("Not an image, not a video, what happened here?");
}
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),
}
}
@@ -196,19 +263,14 @@ pub async fn generate_media_records(
));
}
// if a video has been uploaded, it takes priority as you can only have 1 video per post
if !videos.is_empty() {
embed = Some(atrium_api::types::Union::Refs(
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedVideoMain(Box::new(
videos[0].clone().into(),
)),
))
}
embed
}
async fn upload_media(is_image: bool, bsky: &BskyAgent, u: &str) -> Result<Output, Box<dyn Error>> {
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 {

View File

@@ -18,7 +18,9 @@ mod utils;
use utils::{generate_multi_tweets, strip_everything};
mod bsky;
use bsky::{build_post_record, generate_media_records, get_session, BskyReply};
use bsky::{
build_post_record, generate_embed_records, generate_media_records, get_session, BskyReply,
};
use rusqlite::Connection;
@@ -27,7 +29,8 @@ pub async fn run(config: &Config) {
let conn = Connection::open(&config.oolatoocs.db_path)
.unwrap_or_else(|e| panic!("Cannot open DB: {}", e));
let mastodon = get_mastodon_instance(&config.mastodon);
let mastodon = get_mastodon_instance(&config.mastodon)
.unwrap_or_else(|e| panic!("Cannot instantiate Mastodon: {}", e));
let bluesky = get_session(&config.bluesky)
.await
@@ -151,14 +154,21 @@ pub async fn run(config: &Config) {
};
// treats medias
let record_medias = generate_media_records(&bluesky, &toot.media_attachments).await;
let mut record_embed = generate_media_records(&bluesky, &toot.media_attachments).await;
// treats embed cards if any
if let Some(card) = &toot.card {
if record_embed.is_none() {
record_embed = generate_embed_records(&bluesky, card).await;
}
}
// posts corresponding tweet
let record = build_post_record(
&config.bluesky,
&tweet_content,
&toot.language,
record_medias,
record_embed,
&record_reply_to,
)
.await

View File

@@ -12,12 +12,12 @@ use std::error::Error;
use std::io::stdin;
/// Get Mastodon Object instance
pub fn get_mastodon_instance(config: &MastodonConfig) -> Mastodon {
Mastodon::new(
pub fn get_mastodon_instance(config: &MastodonConfig) -> Result<Mastodon, Box<dyn Error>> {
Ok(Mastodon::new(
config.base.to_string(),
Some(config.token.to_string()),
None,
)
)?)
}
/// Get the edited_at field from the specified toot
@@ -71,7 +71,8 @@ pub async fn get_mastodon_timeline_since(
/// Most of this function is a direct copy/paste of the official `elefren` crate
#[tokio::main]
pub async fn register(host: &str) {
let mastodon = generator(megalodon::SNS::Mastodon, host.to_string(), None, None);
let mastodon = generator(megalodon::SNS::Mastodon, host.to_string(), None, None)
.expect("Cannot build Mastodon generator object");
let options = AppInputOptions {
redirect_uris: None,

View File

@@ -82,10 +82,7 @@ pub fn write_state(conn: &Connection, t: TootRecord) -> Result<(), Box<dyn Error
/// Initiates the DB from path
pub fn init_db(d: &str) -> Result<(), Box<dyn Error>> {
debug!(
"{}",
format!("Initializing DB for {}", env!("CARGO_PKG_NAME"))
);
debug!("Initializing DB for {}", env!("CARGO_PKG_NAME"));
let conn = Connection::open(d)?;
conn.execute(

View File

@@ -38,7 +38,13 @@ fn twitter_count(content: &str) -> usize {
for word in split_content {
if word.starts_with("http://") || word.starts_with("https://") {
count += 23;
// Its not that simple. Bsky adapts itself to the URL.
// https://github.com -> 10 chars
// https://github.com/ -> 10 chars
// https://github.com/NVNTLabs -> 19 chars
// https://github.com/NVNTLabs/ -> 20 chars
// so taking the maximum here to simplify things
count += 26;
} else {
count += word.chars().count();
}
@@ -100,11 +106,11 @@ mod tests {
let content = "Shoot out to https://y.ml/ !";
assert_eq!(twitter_count(content), 38);
assert_eq!(twitter_count(content), 41);
let content = "this is the link https://www.google.com/tamerelol/youpi/tonperemdr/tarace.html if you like! What if I shit a final";
assert_eq!(twitter_count(content), 76);
assert_eq!(twitter_count(content), 79);
let content = "multi ple space";
@@ -112,7 +118,7 @@ mod tests {
let content = "This link is LEEEEET\n\nhttps://www.factornews.com/actualites/ca-sent-le-sapin-pour-free-radical-design-49985.html";
assert_eq!(twitter_count(content), 45);
assert_eq!(twitter_count(content), 48);
}
#[test]