mirror of
https://framagit.org/veretcle/oolatoocs.git
synced 2025-07-20 20:41:17 +02:00
Compare commits
13 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e7f0c9c6f5 | ||
![]() |
83c8da46e8 | ||
![]() |
823f80729f | ||
![]() |
5969e3a56a | ||
![]() |
3ea2478512 | ||
![]() |
5606d00da2 | ||
![]() |
4cb80b0607 | ||
![]() |
bbe14f1f30 | ||
![]() |
6fbc011914 | ||
![]() |
8f23c2459b | ||
![]() |
26805feadb | ||
![]() |
3a8fd538fc | ||
![]() |
891f46ec2f |
1735
Cargo.lock
generated
1735
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
13
Cargo.toml
13
Cargo.toml
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "oolatoocs"
|
name = "oolatoocs"
|
||||||
version = "4.1.1"
|
version = "4.2.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
@@ -8,19 +8,20 @@ edition = "2021"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
chrono = "^0.4"
|
chrono = "^0.4"
|
||||||
clap = "^4"
|
clap = "^4"
|
||||||
env_logger = "^0.10"
|
env_logger = "^0.11"
|
||||||
|
futures = "^0.3"
|
||||||
html-escape = "^0.2"
|
html-escape = "^0.2"
|
||||||
log = "^0.4"
|
log = "^0.4"
|
||||||
megalodon = "^0.13"
|
megalodon = "^1.0"
|
||||||
oauth1-request = "^0.6"
|
oauth1-request = "^0.6"
|
||||||
regex = "^1.10"
|
regex = "^1.10"
|
||||||
reqwest = { version = "^0.11", features = ["json", "stream", "multipart"] }
|
reqwest = { version = "^0.12", features = ["json", "stream", "multipart"] }
|
||||||
rusqlite = { version = "^0.30", features = ["chrono"] }
|
rusqlite = { version = "^0.33", features = ["chrono"] }
|
||||||
serde = { version = "^1.0", features = ["derive"] }
|
serde = { version = "^1.0", features = ["derive"] }
|
||||||
tokio = { version = "^1.33", features = ["rt-multi-thread", "macros"] }
|
tokio = { version = "^1.33", features = ["rt-multi-thread", "macros"] }
|
||||||
toml = "^0.8"
|
toml = "^0.8"
|
||||||
bsky-sdk = "^0.1"
|
bsky-sdk = "^0.1"
|
||||||
atrium-api = "^0.24"
|
atrium-api = { version = "^0.25", features = ["namespace-appbsky"] }
|
||||||
image = "^0.25"
|
image = "^0.25"
|
||||||
webp = "^0.3"
|
webp = "^0.3"
|
||||||
|
|
||||||
|
149
src/bsky.rs
149
src/bsky.rs
@@ -1,16 +1,20 @@
|
|||||||
use crate::config::BlueskyConfig;
|
use crate::config::BlueskyConfig;
|
||||||
use atrium_api::{
|
use atrium_api::{
|
||||||
app::bsky::feed::post::RecordData, com::atproto::repo::upload_blob::Output,
|
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::{
|
use bsky_sdk::{
|
||||||
agent::config::{Config, FileStore},
|
agent::config::{Config, FileStore},
|
||||||
rich_text::RichText,
|
rich_text::RichText,
|
||||||
BskyAgent,
|
BskyAgent,
|
||||||
};
|
};
|
||||||
|
use futures::{stream, StreamExt};
|
||||||
use image::ImageReader;
|
use image::ImageReader;
|
||||||
use log::{debug, error};
|
use log::{debug, error, warn};
|
||||||
use megalodon::entities::attachment::{Attachment, AttachmentType};
|
use megalodon::entities::{
|
||||||
|
attachment::{Attachment, AttachmentType},
|
||||||
|
card::Card,
|
||||||
|
};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use std::{error::Error, fs::exists, io::Cursor};
|
use std::{error::Error, fs::exists, io::Cursor};
|
||||||
use webp::*;
|
use webp::*;
|
||||||
@@ -135,7 +139,7 @@ async fn get_record(
|
|||||||
cid: None,
|
cid: None,
|
||||||
collection: atrium_api::types::string::Nsid::new("app.bsky.feed.post".to_string())?,
|
collection: atrium_api::types::string::Nsid::new("app.bsky.feed.post".to_string())?,
|
||||||
repo: atrium_api::types::string::Handle::new(config.to_string())?.into(),
|
repo: atrium_api::types::string::Handle::new(config.to_string())?.into(),
|
||||||
rkey: rkey.to_string(),
|
rkey: RecordKey::new(rkey.to_string())?,
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
)
|
)
|
||||||
@@ -144,7 +148,43 @@ async fn get_record(
|
|||||||
Ok(record)
|
Ok(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
// it’s 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(
|
pub async fn generate_media_records(
|
||||||
bsky: &BskyAgent,
|
bsky: &BskyAgent,
|
||||||
media_attach: &[Attachment],
|
media_attach: &[Attachment],
|
||||||
@@ -152,39 +192,66 @@ pub async fn generate_media_records(
|
|||||||
let mut embed: Option<
|
let mut embed: Option<
|
||||||
atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>,
|
atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>,
|
||||||
> = None;
|
> = 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() {
|
let image_media_attach: Vec<_> = media_attach
|
||||||
match media.r#type {
|
.iter()
|
||||||
AttachmentType::Image => {
|
.filter(|x| x.r#type == AttachmentType::Image)
|
||||||
let blob = upload_media(true, bsky, &media.url).await.unwrap();
|
.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 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.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 don’t 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 {
|
atrium_api::app::bsky::embed::images::ImageData {
|
||||||
alt: media
|
alt: media
|
||||||
.description
|
.description
|
||||||
.clone()
|
.clone()
|
||||||
.map_or("".to_string(), |v| v.to_owned()),
|
.map_or("".to_string(), |v| v.to_owned()),
|
||||||
aspect_ratio: None,
|
aspect_ratio: None,
|
||||||
image: blob.data.blob,
|
image: i.data.blob,
|
||||||
}
|
}
|
||||||
.into(),
|
})
|
||||||
);
|
})
|
||||||
}
|
})
|
||||||
AttachmentType::Gifv | AttachmentType::Video => {
|
.buffered(4);
|
||||||
let blob = upload_media(false, bsky, &media.url).await.unwrap();
|
|
||||||
|
|
||||||
videos.push(atrium_api::app::bsky::embed::video::MainData {
|
let mut images = Vec::new();
|
||||||
alt: media.description.clone(),
|
|
||||||
aspect_ratio: None,
|
while let Some(result) = stream.next().await {
|
||||||
captions: None,
|
match result {
|
||||||
video: blob.data.blob,
|
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),
|
||||||
_ => {
|
|
||||||
error!("Not an image, not a video, what happened here?");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
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 dl = reqwest::get(u).await?;
|
||||||
let content_length = dl.content_length().ok_or("Content length unavailable")?;
|
let content_length = dl.content_length().ok_or("Content length unavailable")?;
|
||||||
let bytes = if content_length <= 1_000_000 || !is_image {
|
let bytes = if content_length <= 1_000_000 || !is_image {
|
||||||
@@ -216,11 +278,20 @@ async fn upload_media(is_image: bool, bsky: &BskyAgent, u: &str) -> Result<Outpu
|
|||||||
} else {
|
} else {
|
||||||
// this is an image and it’s over 1Mb long
|
// this is an image and it’s over 1Mb long
|
||||||
debug!("Img file too large: {}", content_length);
|
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?))
|
let img = ImageReader::new(Cursor::new(dl.bytes().await?))
|
||||||
.with_guessed_format()?
|
.with_guessed_format()?
|
||||||
.decode()?;
|
.decode()?;
|
||||||
let encoder: Encoder = Encoder::from_image(&img)?;
|
let encoder: Encoder = Encoder::from_image(&img)?;
|
||||||
let webp: WebPMemory = encoder.encode(90f32);
|
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()
|
webp.to_vec()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
18
src/lib.rs
18
src/lib.rs
@@ -18,7 +18,9 @@ mod utils;
|
|||||||
use utils::{generate_multi_tweets, strip_everything};
|
use utils::{generate_multi_tweets, strip_everything};
|
||||||
|
|
||||||
mod bsky;
|
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;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
@@ -27,7 +29,8 @@ pub async fn run(config: &Config) {
|
|||||||
let conn = Connection::open(&config.oolatoocs.db_path)
|
let conn = Connection::open(&config.oolatoocs.db_path)
|
||||||
.unwrap_or_else(|e| panic!("Cannot open DB: {}", e));
|
.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)
|
let bluesky = get_session(&config.bluesky)
|
||||||
.await
|
.await
|
||||||
@@ -151,14 +154,21 @@ pub async fn run(config: &Config) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// treats medias
|
// 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
|
// posts corresponding tweet
|
||||||
let record = build_post_record(
|
let record = build_post_record(
|
||||||
&config.bluesky,
|
&config.bluesky,
|
||||||
&tweet_content,
|
&tweet_content,
|
||||||
&toot.language,
|
&toot.language,
|
||||||
record_medias,
|
record_embed,
|
||||||
&record_reply_to,
|
&record_reply_to,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
@@ -12,12 +12,12 @@ use std::error::Error;
|
|||||||
use std::io::stdin;
|
use std::io::stdin;
|
||||||
|
|
||||||
/// Get Mastodon Object instance
|
/// Get Mastodon Object instance
|
||||||
pub fn get_mastodon_instance(config: &MastodonConfig) -> Mastodon {
|
pub fn get_mastodon_instance(config: &MastodonConfig) -> Result<Mastodon, Box<dyn Error>> {
|
||||||
Mastodon::new(
|
Ok(Mastodon::new(
|
||||||
config.base.to_string(),
|
config.base.to_string(),
|
||||||
Some(config.token.to_string()),
|
Some(config.token.to_string()),
|
||||||
None,
|
None,
|
||||||
)
|
)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the edited_at field from the specified toot
|
/// 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
|
/// Most of this function is a direct copy/paste of the official `elefren` crate
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
pub async fn register(host: &str) {
|
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 {
|
let options = AppInputOptions {
|
||||||
redirect_uris: None,
|
redirect_uris: None,
|
||||||
|
@@ -82,10 +82,7 @@ pub fn write_state(conn: &Connection, t: TootRecord) -> Result<(), Box<dyn Error
|
|||||||
|
|
||||||
/// Initiates the DB from path
|
/// Initiates the DB from path
|
||||||
pub fn init_db(d: &str) -> Result<(), Box<dyn Error>> {
|
pub fn init_db(d: &str) -> Result<(), Box<dyn Error>> {
|
||||||
debug!(
|
debug!("Initializing DB for {}", env!("CARGO_PKG_NAME"));
|
||||||
"{}",
|
|
||||||
format!("Initializing DB for {}", env!("CARGO_PKG_NAME"))
|
|
||||||
);
|
|
||||||
let conn = Connection::open(d)?;
|
let conn = Connection::open(d)?;
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
14
src/utils.rs
14
src/utils.rs
@@ -38,7 +38,13 @@ fn twitter_count(content: &str) -> usize {
|
|||||||
|
|
||||||
for word in split_content {
|
for word in split_content {
|
||||||
if word.starts_with("http://") || word.starts_with("https://") {
|
if word.starts_with("http://") || word.starts_with("https://") {
|
||||||
count += 23;
|
// It’s 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 {
|
} else {
|
||||||
count += word.chars().count();
|
count += word.chars().count();
|
||||||
}
|
}
|
||||||
@@ -100,11 +106,11 @@ mod tests {
|
|||||||
|
|
||||||
let content = "Shoot out to https://y.ml/ !";
|
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";
|
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";
|
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";
|
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]
|
#[test]
|
||||||
|
Reference in New Issue
Block a user