mirror of
https://framagit.org/veretcle/oolatoocs.git
synced 2025-12-06 06:43:15 +01:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cda013272 | ||
|
|
aee880e4bb | ||
|
|
498095d3a8 | ||
|
|
5ee64014eb | ||
|
|
639582ba59 | ||
|
|
43ca862d5a |
20
.gitea/workflows/check.yml
Normal file
20
.gitea/workflows/check.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
|
||||
name: rust-check
|
||||
|
||||
on: # yamllint disable-line rule:truthy
|
||||
- push
|
||||
|
||||
jobs:
|
||||
rust-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: "clippy,rustfmt"
|
||||
- run: cargo fmt -- --check
|
||||
- run: cargo check
|
||||
- run: cargo clippy -- -D warnings
|
||||
- run: cargo test
|
||||
- run: cargo build
|
||||
23
.gitea/workflows/release.yml
Normal file
23
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
|
||||
name: rust-release
|
||||
|
||||
on: # yamllint disable-line rule:truthy
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Get package name
|
||||
run: echo "CARGO_PKG_NAME=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].name')" >> $GITEA_ENV
|
||||
- run: cargo build --release
|
||||
- uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
files: |-
|
||||
target/release/${{ env.CARGO_PKG_NAME }}
|
||||
api_key: '${{ secrets.RELEASE_TOKEN }}'
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1926,7 +1926,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "oolatoocs"
|
||||
version = "4.4.2"
|
||||
version = "4.5.2"
|
||||
dependencies = [
|
||||
"atrium-api",
|
||||
"bsky-sdk",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "oolatoocs"
|
||||
version = "4.4.2"
|
||||
version = "4.5.2"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
162
src/bsky.rs
162
src/bsky.rs
@@ -1,4 +1,4 @@
|
||||
use crate::{config::BlueskyConfig, OolatoocsError};
|
||||
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,
|
||||
@@ -148,9 +148,96 @@ async fn get_record(
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// Generate a Union of embed records to be built-in into records
|
||||
/// In case an embed cannot be uploaded/created, this calling function silently gets Option instead
|
||||
/// of failing
|
||||
pub async fn generate_embed_records(
|
||||
config: &BlueskyConfig,
|
||||
bsky: &BskyAgent,
|
||||
qid: Option<&str>,
|
||||
media_attach: &[Attachment],
|
||||
card: &Option<Card>,
|
||||
) -> Result<
|
||||
Option<atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>>,
|
||||
Box<dyn Error + Send + Sync>,
|
||||
> {
|
||||
// handle quote if any
|
||||
let quote_embed = match qid {
|
||||
Some(q) => generate_quote_records(config, q).await.ok(),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// handle medias if any
|
||||
let media_embed = if media_attach.len() > usize::from(0u8) {
|
||||
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();
|
||||
|
||||
if !video_media_attach.is_empty() {
|
||||
generate_video_record(bsky, video_media_attach).await.ok()
|
||||
} else if !image_media_attach.is_empty() {
|
||||
generate_images_records(bsky, image_media_attach).await.ok()
|
||||
} else {
|
||||
return Err(OolatoocsError::new("A media attached is not an image nor a video").into());
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// handle webcard if any
|
||||
let webcard_embed = match card {
|
||||
Some(t) => generate_webcard_records(bsky, t).await.ok(),
|
||||
None => None,
|
||||
};
|
||||
|
||||
if let Some(q) = quote_embed {
|
||||
if let Some(m) = media_embed {
|
||||
let medias_mapped = match m {
|
||||
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedImagesMain(a) => atrium_api::app::bsky::embed::record_with_media::MainMediaRefs::AppBskyEmbedImagesMain(a),
|
||||
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedVideoMain(a) => atrium_api::app::bsky::embed::record_with_media::MainMediaRefs::AppBskyEmbedVideoMain(a),
|
||||
_ => return Err(OolatoocsError::new("Something went terribly wrong when trying to add image/video to quote record: can’t decapsulate media").into()),
|
||||
};
|
||||
let quote_mapped = match q {
|
||||
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedRecordMain(
|
||||
a,
|
||||
) => a,
|
||||
_ => return Err(OolatoocsError::new("Something went terribly wrong when trying to add image/video to quote record: can’t decapsulate quote").into()),
|
||||
};
|
||||
Some(atrium_api::types::Union::Refs(
|
||||
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedRecordWithMediaMain(
|
||||
Box::new(
|
||||
atrium_api::app::bsky::embed::record_with_media::MainData {
|
||||
media: atrium_api::types::Union::Refs(medias_mapped),
|
||||
record: (*quote_mapped),
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
),
|
||||
))
|
||||
} else {
|
||||
Some(atrium_api::types::Union::Refs(q))
|
||||
}
|
||||
} else if media_embed.is_some() {
|
||||
media_embed.map(atrium_api::types::Union::Refs)
|
||||
} else if webcard_embed.is_some() {
|
||||
webcard_embed.map(atrium_api::types::Union::Refs)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Generate an quote embed record
|
||||
/// it is encapsulated in Option to prevent this function from failing
|
||||
pub async fn generate_quote_records(
|
||||
async fn generate_quote_records(
|
||||
config: &BlueskyConfig,
|
||||
quote_id: &str,
|
||||
) -> Result<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error>> {
|
||||
@@ -173,7 +260,7 @@ pub async fn generate_quote_records(
|
||||
|
||||
/// 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(
|
||||
async fn generate_webcard_records(
|
||||
bsky: &BskyAgent,
|
||||
card: &Card,
|
||||
) -> Result<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error + Send + Sync>> {
|
||||
@@ -199,45 +286,12 @@ pub async fn generate_webcard_records(
|
||||
)
|
||||
}
|
||||
|
||||
/// 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(
|
||||
/// Generate an array of Bsky image media records
|
||||
async fn generate_images_records(
|
||||
bsky: &BskyAgent,
|
||||
media_attach: &[Attachment],
|
||||
media_attach: Vec<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: None,
|
||||
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)
|
||||
let mut stream = stream::iter(media_attach)
|
||||
.map(|media| {
|
||||
let bsky = bsky.clone();
|
||||
tokio::task::spawn(async move {
|
||||
@@ -248,7 +302,9 @@ pub async fn generate_media_records(
|
||||
.description
|
||||
.clone()
|
||||
.map_or("".to_string(), |v| v.to_owned()),
|
||||
aspect_ratio: None,
|
||||
aspect_ratio: convert_aspect_ratio(
|
||||
&media.meta.as_ref().and_then(|m| m.original.clone()),
|
||||
),
|
||||
image: i.data.blob,
|
||||
}
|
||||
})
|
||||
@@ -277,6 +333,30 @@ pub async fn generate_media_records(
|
||||
Err(OolatoocsError::new("Cannot embed media").into())
|
||||
}
|
||||
|
||||
/// Generate a video Bsky media record
|
||||
async fn generate_video_record(
|
||||
bsky: &BskyAgent,
|
||||
media_attach: Vec<Attachment>,
|
||||
) -> Result<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error + Send + Sync>> {
|
||||
// treat only the very first video, ignore the rest
|
||||
let media = &media_attach[0];
|
||||
let blob = upload_media(false, bsky, &media.url).await?;
|
||||
|
||||
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(),
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
async fn upload_media(
|
||||
is_image: bool,
|
||||
bsky: &BskyAgent,
|
||||
|
||||
80
src/lib.rs
80
src/lib.rs
@@ -18,10 +18,7 @@ mod utils;
|
||||
use utils::{generate_multi_tweets, strip_everything};
|
||||
|
||||
mod bsky;
|
||||
use bsky::{
|
||||
build_post_record, generate_media_records, generate_quote_records, generate_webcard_records,
|
||||
get_session, BskyReply,
|
||||
};
|
||||
use bsky::{build_post_record, generate_embed_records, get_session, BskyReply};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
@@ -161,69 +158,24 @@ pub async fn run(config: &Config) {
|
||||
});
|
||||
};
|
||||
|
||||
// handle quote if any
|
||||
let quote_embed = match toot.reblog {
|
||||
Some(r) => {
|
||||
let quote_record = read_state(&conn, Some(r.id.parse::<u64>().unwrap()));
|
||||
match quote_record {
|
||||
Ok(Some(q)) => generate_quote_records(&config.bluesky, &q.record_uri)
|
||||
.await
|
||||
.ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// get quote_id if any
|
||||
let quote_id = match toot.reblog {
|
||||
Some(r) => match read_state(&conn, Some(r.id.parse::<u64>().unwrap())) {
|
||||
Ok(q) => q.map(|x| x.record_uri.to_owned()),
|
||||
_ => None,
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
// handle medias if any
|
||||
let media_embed = if toot.media_attachments.len() > usize::from(0u8) {
|
||||
generate_media_records(&bluesky, &toot.media_attachments)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// handle webcard if any
|
||||
let webcard_embed = match toot.card {
|
||||
Some(t) => generate_webcard_records(&bluesky, &t).await.ok(),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let record_embed = if quote_embed.is_some() {
|
||||
if media_embed.is_some() {
|
||||
let medias_mapped = match media_embed.unwrap() {
|
||||
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedImagesMain(a) => atrium_api::app::bsky::embed::record_with_media::MainMediaRefs::AppBskyEmbedImagesMain(a),
|
||||
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedVideoMain(a) => atrium_api::app::bsky::embed::record_with_media::MainMediaRefs::AppBskyEmbedVideoMain(a),
|
||||
_ => continue, // this should NEVER happen as Media are either Video or
|
||||
// Images at this point
|
||||
};
|
||||
let quote_mapped = match quote_embed.unwrap() {
|
||||
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedRecordMain(
|
||||
a,
|
||||
) => a,
|
||||
_ => continue, // again, this should NEVER happen
|
||||
};
|
||||
Some(atrium_api::types::Union::Refs(
|
||||
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedRecordWithMediaMain(
|
||||
Box::new(
|
||||
atrium_api::app::bsky::embed::record_with_media::MainData {
|
||||
media: atrium_api::types::Union::Refs(medias_mapped),
|
||||
record: (*quote_mapped),
|
||||
}.into()
|
||||
)
|
||||
)
|
||||
))
|
||||
} else {
|
||||
quote_embed.map(atrium_api::types::Union::Refs)
|
||||
}
|
||||
} else if media_embed.is_some() {
|
||||
media_embed.map(atrium_api::types::Union::Refs)
|
||||
} else if webcard_embed.is_some() {
|
||||
webcard_embed.map(atrium_api::types::Union::Refs)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let record_embed = generate_embed_records(
|
||||
&config.bluesky,
|
||||
&bluesky,
|
||||
quote_id.as_deref(),
|
||||
&toot.media_attachments,
|
||||
&toot.card,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("Cannot embed record for {}: {}", &toot.id, e));
|
||||
|
||||
// posts corresponding tweet
|
||||
let record = build_post_record(
|
||||
|
||||
114
src/utils.rs
114
src/utils.rs
@@ -1,7 +1,8 @@
|
||||
use atrium_api::{app::bsky::embed::defs::AspectRatioData, types::Object};
|
||||
use html_escape::decode_html_entities;
|
||||
use megalodon::entities::status::Tag;
|
||||
use megalodon::entities::{attachment::MetaSub, status::Tag};
|
||||
use regex::Regex;
|
||||
use std::error::Error;
|
||||
use std::{error::Error, num::NonZeroU64};
|
||||
|
||||
/// Generate 2 contents out of 1 if that content is > 300 chars, None else
|
||||
pub fn generate_multi_tweets(content: &str) -> Option<(String, String)> {
|
||||
@@ -110,10 +111,119 @@ fn strip_html_tags(input: &str) -> String {
|
||||
data
|
||||
}
|
||||
|
||||
pub fn convert_aspect_ratio(m: &Option<MetaSub>) -> Option<Object<AspectRatioData>> {
|
||||
match m {
|
||||
Some(ms) => {
|
||||
if ms.height.is_some_and(|x| x > 0) && ms.width.is_some_and(|x| x > 0) {
|
||||
Some(
|
||||
AspectRatioData {
|
||||
// unwrap is safe here
|
||||
height: NonZeroU64::new(ms.height.unwrap().into()).unwrap(),
|
||||
width: NonZeroU64::new(ms.width.unwrap().into()).unwrap(),
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_convert_aspect_ratio() {
|
||||
// test None orig aspect ratio
|
||||
let metasub: Option<MetaSub> = None;
|
||||
|
||||
let result = convert_aspect_ratio(&metasub);
|
||||
|
||||
assert_eq!(result, None);
|
||||
|
||||
// test complet with image
|
||||
let metasub = Some(MetaSub {
|
||||
width: Some(1920),
|
||||
height: Some(1080),
|
||||
size: Some(String::from("1920x1080")),
|
||||
aspect: Some(1.7777777777777777),
|
||||
frame_rate: None,
|
||||
duration: None,
|
||||
bitrate: None,
|
||||
});
|
||||
|
||||
let expected_result = Some(
|
||||
AspectRatioData {
|
||||
height: NonZeroU64::new(1080).unwrap(),
|
||||
width: NonZeroU64::new(1920).unwrap(),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
|
||||
let result = convert_aspect_ratio(&metasub);
|
||||
|
||||
assert_eq!(result, expected_result);
|
||||
|
||||
// test complete with video
|
||||
let metasub = Some(MetaSub {
|
||||
width: Some(500),
|
||||
height: Some(278),
|
||||
size: None,
|
||||
aspect: None,
|
||||
frame_rate: Some(String::from("10/1")),
|
||||
duration: Some(0.9),
|
||||
bitrate: Some(973191),
|
||||
});
|
||||
|
||||
let expected_result = Some(
|
||||
AspectRatioData {
|
||||
height: NonZeroU64::new(278).unwrap(),
|
||||
width: NonZeroU64::new(500).unwrap(),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
|
||||
let result = convert_aspect_ratio(&metasub);
|
||||
|
||||
assert_eq!(result, expected_result);
|
||||
|
||||
/* test broken shit
|
||||
* that should never happened but you never know
|
||||
*/
|
||||
// zero width
|
||||
let metasub = Some(MetaSub {
|
||||
width: Some(0),
|
||||
height: Some(278),
|
||||
size: None,
|
||||
aspect: None,
|
||||
frame_rate: Some(String::from("10/1")),
|
||||
duration: Some(0.9),
|
||||
bitrate: Some(973191),
|
||||
});
|
||||
|
||||
let result = convert_aspect_ratio(&metasub);
|
||||
|
||||
assert_eq!(result, None);
|
||||
|
||||
// None height
|
||||
let metasub = Some(MetaSub {
|
||||
width: Some(500),
|
||||
height: None,
|
||||
size: None,
|
||||
aspect: None,
|
||||
frame_rate: Some(String::from("10/1")),
|
||||
duration: Some(0.9),
|
||||
bitrate: Some(973191),
|
||||
});
|
||||
|
||||
let result = convert_aspect_ratio(&metasub);
|
||||
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_twitter_count() {
|
||||
let content = "tamerelol?! 🐵";
|
||||
|
||||
Reference in New Issue
Block a user