From f8827d3ba449ac3f6c067daad0989e2b2d10e223 Mon Sep 17 00:00:00 2001 From: Denis-Cosmin NUTIU Date: Mon, 30 Dec 2024 17:48:07 +0200 Subject: [PATCH] implement basic content posting without images --- bot/Cargo.toml | 3 +- bot/src/bluesky.rs | 5 +- bot/src/bluesky/atproto.rs | 159 ++++++++++++++++++++++++++++++++- bot/src/{ => bluesky}/token.rs | 0 bot/src/main.rs | 16 +++- 5 files changed, 175 insertions(+), 8 deletions(-) rename bot/src/{ => bluesky}/token.rs (100%) diff --git a/bot/Cargo.toml b/bot/Cargo.toml index a7cfea3..c4abc70 100644 --- a/bot/Cargo.toml +++ b/bot/Cargo.toml @@ -15,4 +15,5 @@ anyhow = "1.0.95" serde = { version = "1.0.216", features = ["derive"] } base64 = "0.22.1" serde_json = "1.0.134" -reqwest = { version = "0.12.11", features = ["json"] } \ No newline at end of file +reqwest = { version = "0.12.11", features = ["json"] } +chrono = "0.4.39" \ No newline at end of file diff --git a/bot/src/bluesky.rs b/bot/src/bluesky.rs index 0a50162..ab7e85e 100644 --- a/bot/src/bluesky.rs +++ b/bot/src/bluesky.rs @@ -1,8 +1,9 @@ -mod atproto; +pub(crate) mod atproto; +mod token; use crate::bluesky::atproto::ATProtoServerCreateSession; -use crate::token::Token; use reqwest::Body; +use token::Token; /// The BlueSky client used to interact with the platform. pub struct BlueSkyClient { diff --git a/bot/src/bluesky/atproto.rs b/bot/src/bluesky/atproto.rs index 7cf1c4c..2756a90 100644 --- a/bot/src/bluesky/atproto.rs +++ b/bot/src/bluesky/atproto.rs @@ -1,7 +1,164 @@ +use chrono::{DateTime, Local, Utc}; +use post::NewsPost; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Debug, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Default)] pub struct ATProtoServerCreateSession { pub(crate) identifier: String, pub(crate) password: String, } + +#[derive(Serialize, Deserialize, Debug)] +pub struct ExternalRecordEmbed { + uri: String, + title: String, + description: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ATprotoRepoCreateRecordEmbed { + #[serde(rename(serialize = "$type", deserialize = "$type"))] + embed_type: String, + external: ExternalRecordEmbed, +} + +impl ATprotoRepoCreateRecordEmbed { + fn new(uri: &str, title: &str, description: &str) -> Self { + ATprotoRepoCreateRecordEmbed { + embed_type: "app.bsky.embed.external".to_string(), + external: ExternalRecordEmbed { + uri: uri.to_string(), + title: title.to_string(), + description: description.to_string(), + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ATprotoRepoCreateRecordRecord { + text: String, + #[serde(rename(serialize = "createdAt", deserialize = "createdAt"))] + created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + embed: Option, +} + +impl ATprotoRepoCreateRecordRecord { + fn new(text: &str, date: DateTime, embed: Option) -> Self { + ATprotoRepoCreateRecordRecord { + text: text.to_string(), + created_at: date.to_rfc3339(), + embed, + } + } +} +#[derive(Serialize, Deserialize, Debug)] +pub struct ATProtoRepoCreateRecord { + pub repo: String, + collection: String, + record: ATprotoRepoCreateRecordRecord, +} + +impl ATProtoRepoCreateRecord { + fn new(handle: &str, record: ATprotoRepoCreateRecordRecord) -> Self { + ATProtoRepoCreateRecord { + repo: handle.to_string(), + collection: "app.bsky.feed.post".to_string(), + record, + } + } +} + +impl From for ATProtoRepoCreateRecord { + fn from(post: NewsPost) -> Self { + let dt = Local::now(); + let dt_utc = DateTime::::from_naive_utc_and_offset(dt.naive_utc(), Utc); + + ATProtoRepoCreateRecord::new( + "", + ATprotoRepoCreateRecordRecord::new( + post.title.clone().unwrap_or(String::from("")).as_str(), + dt_utc, + Some(ATprotoRepoCreateRecordEmbed::new( + post.link.unwrap().as_str(), + post.title.unwrap().as_str(), + post.summary.unwrap().as_str(), + )), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDateTime; + + #[test] + fn test_atproto_server_create_session_serialization() -> Result<(), anyhow::Error> { + let session = ATProtoServerCreateSession { + identifier: "user".to_string(), + password: "pass".to_string(), + }; + + let json = serde_json::to_string(&session)?; + + assert_eq!(json, r#"{"identifier":"user","password":"pass"}"#); + + Ok(()) + } + + #[test] + fn test_atproto_repo_create_record_record_no_embed_serialization() -> Result<(), anyhow::Error> + { + let time_str = "2024-12-30T13:45:00"; + let naive_datetime = NaiveDateTime::parse_from_str(time_str, "%Y-%m-%dT%H:%M:%S").unwrap(); + + let session = ATProtoRepoCreateRecord::new( + "nuculabs.dev", + ATprotoRepoCreateRecordRecord::new( + "some post", + DateTime::from_naive_utc_and_offset(naive_datetime, Utc), + None, + ), + ); + + let json = serde_json::to_string(&session)?; + + assert_eq!( + json, + r#"{"repo":"nuculabs.dev","collection":"app.bsky.feed.post","record":{"text":"some post","createdAt":"2024-12-30T13:45:00+00:00"}}"# + ); + + Ok(()) + } + + #[test] + fn test_atproto_repo_create_record_record_embed_serialization() -> Result<(), anyhow::Error> { + let time_str = "2024-12-30T13:45:00"; + let naive_datetime = NaiveDateTime::parse_from_str(time_str, "%Y-%m-%dT%H:%M:%S").unwrap(); + + let session = ATProtoRepoCreateRecord::new( + "nuculabs.dev", + ATprotoRepoCreateRecordRecord::new( + "some post", + DateTime::from_naive_utc_and_offset(naive_datetime, Utc), + Some(ATprotoRepoCreateRecordEmbed::new( + "https://some-news.ro/some", + "Some very important news", + "The description of the news", + )), + ), + ); + + let json = serde_json::to_string(&session)?; + + assert_eq!( + json, + r#"{"repo":"nuculabs.dev","collection":"app.bsky.feed.post","record":{"text":"some post","createdAt":"2024-12-30T13:45:00+00:00","embed":{"$type":"app.bsky.embed.external","external":{"uri":"https://some-news.ro/some","title":"Some very important news","description":"The description of the news"}}}}"# + ); + + Ok(()) + } +} diff --git a/bot/src/token.rs b/bot/src/bluesky/token.rs similarity index 100% rename from bot/src/token.rs rename to bot/src/bluesky/token.rs diff --git a/bot/src/main.rs b/bot/src/main.rs index 1ba91d7..7866dab 100644 --- a/bot/src/main.rs +++ b/bot/src/main.rs @@ -12,7 +12,6 @@ use std::thread; mod bluesky; mod cli; -mod token; //noinspection DuplicatedCode /// Sets up a signal handler in a separate thread to handle SIGINT and SIGTERM signals. @@ -69,9 +68,18 @@ async fn main() -> Result<(), anyhow::Error> { .await { Ok(post) => { - let data = ""; // TODO - if let Err(err) = bluesky_client.post(data).await { - error!("failed to post: {post:?} {err}") + let mut data: bluesky::atproto::ATProtoRepoCreateRecord = post.clone().into(); + data.repo = args.bluesky_handle.clone(); + let json = serde_json::to_string(&data); + match json { + Ok(json) => { + if let Err(err) = bluesky_client.post(json).await { + error!("failed to post: {post:?} {err}") + } + } + Err(err) => { + error!("failed to convert post to json: {post:?} {err}") + } } } Err(err) => {