From 4c0657839363152dd607e8815f485b1cc9a1ca53 Mon Sep 17 00:00:00 2001 From: sugyan Date: Thu, 15 Aug 2024 00:25:42 +0900 Subject: [PATCH 1/2] WIP: Add post builder to SDK --- Cargo.lock | 2 + bsky-sdk/Cargo.toml | 2 + bsky-sdk/src/feed.rs | 1 + bsky-sdk/src/feed/post.rs | 559 ++++++++++++++++++ bsky-sdk/src/lib.rs | 1 + .../images/dummy_600x400_ffffff_cccccc.png | Bin 0 -> 8493 bytes 6 files changed, 565 insertions(+) create mode 100644 bsky-sdk/src/feed.rs create mode 100644 bsky-sdk/src/feed/post.rs create mode 100644 bsky-sdk/tests/data/images/dummy_600x400_ffffff_cccccc.png diff --git a/Cargo.lock b/Cargo.lock index bf2d6e32..2166e826 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -287,6 +287,8 @@ dependencies = [ "atrium-api", "atrium-xrpc-client", "chrono", + "futures", + "http 1.1.0", "ipld-core", "psl", "regex", diff --git a/bsky-sdk/Cargo.toml b/bsky-sdk/Cargo.toml index d6b133d2..c14cc1a5 100644 --- a/bsky-sdk/Cargo.toml +++ b/bsky-sdk/Cargo.toml @@ -17,6 +17,8 @@ async-trait.workspace = true atrium-api.workspace = true atrium-xrpc-client = { workspace = true, optional = true } chrono.workspace = true +futures.workspace = true +http.workspace = true psl = { version = "2.1.42", optional = true } regex.workspace = true serde = { workspace = true, features = ["derive"] } diff --git a/bsky-sdk/src/feed.rs b/bsky-sdk/src/feed.rs new file mode 100644 index 00000000..e8b6291a --- /dev/null +++ b/bsky-sdk/src/feed.rs @@ -0,0 +1 @@ +pub mod post; diff --git a/bsky-sdk/src/feed/post.rs b/bsky-sdk/src/feed/post.rs new file mode 100644 index 00000000..498295ce --- /dev/null +++ b/bsky-sdk/src/feed/post.rs @@ -0,0 +1,559 @@ +use crate::rich_text::RichText; +use crate::BskyAgent; +use atrium_api::agent::store::SessionStore; +use atrium_api::app::bsky::embed::images; +use atrium_api::app::bsky::feed::post::{RecordData, RecordEmbedRefs, RecordLabelsRefs, ReplyRef}; +use atrium_api::app::bsky::richtext::facet; +use atrium_api::com::atproto::label::defs::{SelfLabelData, SelfLabelsData}; +use atrium_api::record::KnownRecord; +use atrium_api::types::string::{Datetime, Language}; +use atrium_api::types::Union; +use atrium_api::xrpc::XrpcClient; +use futures::future; +use std::fs::File; +use std::io::Read; +use std::sync::Arc; +use thiserror::Error; + +#[derive(Error, Debug)] + +pub enum BuilderError { + #[error(transparent)] + Sdk(#[from] crate::error::Error), + #[error(transparent)] + Io(#[from] std::io::Error), +} + +pub type Result = core::result::Result; + +#[derive(Debug)] +pub struct RecordBuilder { + created_at: Option, + embed: Option>, + facets: Option>, + labels: Option>, + langs: Option>, + reply: Option, + tags: Option>, + text: String, +} + +impl RecordBuilder { + pub fn new(text: impl AsRef) -> Self { + Self { + created_at: None, + embed: None, + facets: None, + labels: None, + langs: None, + reply: None, + tags: None, + text: text.as_ref().into(), + } + } + pub fn created_at(mut self, created_at: Datetime) -> Self { + self.created_at = Some(created_at); + self + } + pub fn embed(mut self, embed: Union) -> Self { + self.embed = Some(embed); + self + } + pub fn facets(mut self, facets: Vec) -> Self { + if !facets.is_empty() { + self.facets = Some(facets); + } + self + } + pub fn labels(mut self, labels: Vec>) -> Self { + if !labels.is_empty() { + self.labels = Some(labels.into_iter().map(|s| s.as_ref().into()).collect()); + } + self + } + pub fn langs(mut self, langs: Vec) -> Self { + if !langs.is_empty() { + self.langs = Some(langs); + } + self + } + pub fn reply(mut self, reply: ReplyRef) -> Self { + self.reply = Some(reply); + self + } + pub fn tags(mut self, tags: &[impl AsRef]) -> Self { + self.tags = Some(tags.iter().map(|s| s.as_ref().into()).collect()); + self + } + pub fn build(self) -> KnownRecord { + KnownRecord::AppBskyFeedPost(Box::new( + RecordData { + created_at: self.created_at.unwrap_or(Datetime::now()), + embed: self.embed, + entities: None, + facets: self.facets, + labels: self.labels.map(|v| { + Union::Refs(RecordLabelsRefs::ComAtprotoLabelDefsSelfLabels(Box::new( + SelfLabelsData { + values: v + .into_iter() + .map(|s| SelfLabelData { val: s }.into()) + .collect(), + } + .into(), + ))) + }), + langs: self.langs, + reply: self.reply, + tags: self.tags, + text: self.text, + } + .into(), + )) + } +} + +impl From for KnownRecord { + fn from(builder: RecordBuilder) -> Self { + builder.build() + } +} + +#[derive(Debug)] +pub struct Builder { + inner: RecordBuilder, + auto_detect_facets: bool, + embed: Option, +} + +impl Builder { + pub fn new(text: impl AsRef) -> Self { + Self { + inner: RecordBuilder::new(text), + auto_detect_facets: true, + embed: None, + } + } + pub fn auto_detect_facets(mut self, value: bool) -> Self { + self.auto_detect_facets = value; + self + } + pub fn created_at(mut self, created_at: Datetime) -> Self { + self.inner = self.inner.created_at(created_at); + self + } + pub fn embed_images(mut self, images: Vec>) -> Self { + self.embed = Some(Embed::Images( + images.into_iter().map(|val| val.into()).collect(), + )); + self + } + // pub fn embed_external(mut self) -> Self { + // todo!() + // } + // pub fn embed_record(mut self) -> Self { + // todo!() + // } + // pub fn embed_record_with_media(mut self) -> Self { + // todo!() + // } + pub fn facets(mut self, facets: Vec) -> Self { + self.inner = self.inner.facets(facets); + self.auto_detect_facets = false; + self + } + pub fn labels(mut self, labels: Vec>) -> Self { + self.inner = self.inner.labels(labels); + self + } + // pub fn langs(mut self, langs: Vec) -> Self { + // self.langs = Some(langs); + // self + // } + // pub fn reply(mut self, reply: ReplyRef) -> Self { + // self.reply = Some(reply); + // self + // } + pub fn tags(mut self, tags: &[impl AsRef]) -> Self { + self.inner = self.inner.tags(tags); + self + } + pub async fn build(mut self, agent: &BskyAgent) -> Result + where + T: XrpcClient + Send + Sync, + S: SessionStore + Send + Sync, + { + if let Some(embed) = &self.embed { + let refs = match embed { + Embed::Images(image_subjects) => { + let agent = Arc::new(agent); + let mut handles = Vec::new(); + for subject in image_subjects { + match subject { + // read file and upload blob + ImageSubject::Path((path, alt)) => { + let mut input = Vec::with_capacity(path.metadata()?.len() as usize); + File::open(path)?.read_to_end(&mut input)?; + let alt = alt.as_ref().map_or( + path.file_name() + .map(|s| s.to_string_lossy().into()) + .unwrap_or_default(), + |s| s.clone(), + ); + let agent = agent.clone(); + handles.push(async move { + agent.api.com.atproto.repo.upload_blob(input).await.map( + |output| images::ImageData { + alt, + aspect_ratio: None, + image: output.data.blob, + }, + ) + }) + } + ImageSubject::Uri(_) => { + todo!() + } + } + } + let mut images = Vec::new(); + for result in future::join_all(handles).await { + let image_data = result.map_err(|e| BuilderError::Sdk(e.into()))?; + images.push(image_data.into()); + } + RecordEmbedRefs::AppBskyEmbedImagesMain(Box::new( + images::MainData { images }.into(), + )) + } + // _ => { + // todo!() + // } + }; + self.inner = self.inner.embed(Union::Refs(refs)); + } + if self.auto_detect_facets { + if let Some(facets) = RichText::new_with_detect_facets(&self.inner.text) + .await? + .facets + { + self.inner = self.inner.facets(facets); + } + } + Ok(self.inner.build()) + } +} + +#[derive(Debug)] +enum Embed { + Images(Vec), + // External, + // Record, + // RecordWithMedia, +} + +#[derive(Debug)] +pub enum ImageSubject { + Path((std::path::PathBuf, Option)), + Uri((http::Uri, Option)), +} + +impl From for ImageSubject +where + T: AsRef, +{ + fn from(value: T) -> Self { + Self::Path((std::path::PathBuf::from(value.as_ref()), None)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use atrium_api::types::{Blob, BlobRef, CidLink, TypedBlobRef}; + use atrium_api::xrpc::http::{Request, Response}; + use atrium_api::xrpc::types::Header; + use atrium_api::xrpc::HttpClient; + + struct MockClient; + + #[async_trait] + impl HttpClient for MockClient { + async fn send_http( + &self, + request: Request>, + ) -> core::result::Result< + Response>, + Box, + > { + let body = match request.uri().path().strip_prefix("/xrpc/") { + Some(atrium_api::com::atproto::repo::upload_blob::NSID) => r#"{ + "blob": { + "$type": "blob", + "ref": { + "$link": "bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq" + }, + "mimeType": "image/png", + "size": 8493 + } + }"# + .as_bytes() + .to_vec(), + _ => unreachable!(), + }; + Ok(Response::builder() + .header(Header::ContentType, "application/json") + .body(body)?) + } + } + + #[async_trait] + impl XrpcClient for MockClient { + fn base_uri(&self) -> String { + String::new() + } + } + + async fn agent() -> BskyAgent { + BskyAgent::builder() + .client(MockClient) + .build() + .await + .expect("failed to build agent") + } + + #[test] + fn record_builder() { + { + let record = RecordBuilder::new(String::new()).build(); + assert!(matches!(record, KnownRecord::AppBskyFeedPost(_))); + } + { + let now = Datetime::now(); + let record = RecordBuilder::new("foo").created_at(now.clone()).build(); + assert_eq!( + record, + KnownRecord::AppBskyFeedPost(Box::new( + RecordData { + created_at: now, + embed: None, + entities: None, + facets: None, + labels: None, + langs: None, + reply: None, + tags: None, + text: String::from("foo"), + } + .into() + )) + ); + } + { + let now = Datetime::now(); + let record = RecordBuilder::new("bar") + .created_at(now.clone()) + .labels(vec!["baz"]) + .langs(vec![ + "en".parse().expect("invalid lang"), + "ja".parse().expect("invalid lang"), + ]) + .build(); + assert_eq!( + record, + KnownRecord::AppBskyFeedPost(Box::new( + RecordData { + created_at: now, + embed: None, + entities: None, + facets: None, + labels: Some(Union::Refs( + RecordLabelsRefs::ComAtprotoLabelDefsSelfLabels(Box::new( + SelfLabelsData { + values: vec![SelfLabelData { + val: String::from("baz") + } + .into()] + } + .into() + )) + )), + langs: Some(vec![ + "en".parse().expect("invalid lang"), + "ja".parse().expect("invalid lang"), + ]), + reply: None, + tags: None, + text: String::from("bar"), + } + .into() + )) + ); + } + } + + #[tokio::test] + async fn builder_build() { + let record = Builder::new(String::new()) + .build(&agent().await) + .await + .expect("failed to build record"); + assert!(matches!(record, KnownRecord::AppBskyFeedPost(_))); + } + + #[tokio::test] + async fn builder_auto_detect_facets() { + let now = Datetime::now(); + let record = Builder::new("foo #bar https://example.com") + .created_at(now.clone()) + .build(&agent().await) + .await + .expect("failed to build record"); + assert_eq!( + record, + KnownRecord::AppBskyFeedPost(Box::new( + RecordData { + created_at: now, + embed: None, + entities: None, + facets: Some(vec![ + facet::MainData { + features: vec![Union::Refs(facet::MainFeaturesItem::Link(Box::new( + facet::LinkData { + uri: String::from("https://example.com") + } + .into() + )))], + index: facet::ByteSliceData { + byte_end: 28, + byte_start: 9 + } + .into() + } + .into(), + facet::MainData { + features: vec![Union::Refs(facet::MainFeaturesItem::Tag(Box::new( + facet::TagData { + tag: String::from("bar") + } + .into() + )))], + index: facet::ByteSliceData { + byte_end: 8, + byte_start: 4 + } + .into() + } + .into() + ]), + labels: None, + langs: None, + reply: None, + tags: None, + text: String::from("foo #bar https://example.com"), + } + .into() + )) + ); + } + + #[tokio::test] + async fn builder_no_auto_detect_facets() { + { + let now = Datetime::now(); + let record = Builder::new("foo #bar https://example.com") + .created_at(now.clone()) + .auto_detect_facets(false) + .build(&agent().await) + .await + .expect("failed to build record"); + assert_eq!( + record, + KnownRecord::AppBskyFeedPost(Box::new( + RecordData { + created_at: now, + embed: None, + entities: None, + facets: None, + labels: None, + langs: None, + reply: None, + tags: None, + text: String::from("foo #bar https://example.com"), + } + .into() + )) + ); + } + { + let now = Datetime::now(); + let record = Builder::new("foo #bar https://example.com") + .created_at(now.clone()) + .facets(Vec::new()) + .build(&agent().await) + .await + .expect("failed to build record"); + assert_eq!( + record, + KnownRecord::AppBskyFeedPost(Box::new( + RecordData { + created_at: now, + embed: None, + entities: None, + facets: None, + labels: None, + langs: None, + reply: None, + tags: None, + text: String::from("foo #bar https://example.com"), + } + .into() + )) + ); + } + } + + #[tokio::test] + async fn builder_embed() { + let now = Datetime::now(); + let record = Builder::new("embed images") + .created_at(now.clone()) + .embed_images(vec!["tests/data/images/dummy_600x400_ffffff_cccccc.png"]) + .build(&agent().await) + .await + .expect("failed to build record"); + assert_eq!( + record, + KnownRecord::AppBskyFeedPost(Box::new( + RecordData { + created_at: now, + embed: Some(Union::Refs(RecordEmbedRefs::AppBskyEmbedImagesMain( + Box::new( + images::MainData { + images: vec![images::ImageData { + alt: String::from("dummy_600x400_ffffff_cccccc.png"), + aspect_ratio: None, + image: BlobRef::Typed(TypedBlobRef::Blob(Blob { + r#ref: CidLink("bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq".parse().expect("invalid cid")), + mime_type: String::from("image/png"), + size: 8493, + })) + } + .into()] + } + .into() + ) + ))), + entities: None, + facets: None, + labels: None, + langs: None, + reply: None, + tags: None, + text: String::from("embed images"), + } + .into() + )) + ); + } +} diff --git a/bsky-sdk/src/lib.rs b/bsky-sdk/src/lib.rs index a3fac26e..1a5ad7db 100644 --- a/bsky-sdk/src/lib.rs +++ b/bsky-sdk/src/lib.rs @@ -2,6 +2,7 @@ #![doc = include_str!("../README.md")] pub mod agent; mod error; +pub mod feed; pub mod moderation; pub mod preference; pub mod record; diff --git a/bsky-sdk/tests/data/images/dummy_600x400_ffffff_cccccc.png b/bsky-sdk/tests/data/images/dummy_600x400_ffffff_cccccc.png new file mode 100644 index 0000000000000000000000000000000000000000..214c2179e1775213bf2dc377dbdaeb805ca371cd GIT binary patch literal 8493 zcmeHNdpw(G`zMP-snV&Ms<5|fI~LN-lD3qnZqrtaQs?t)4NVZOMoQ{jtfyGF&e}S4 zLx==HLmeK1VJ!(t91^0=m54Y-3GqJNy7%ATU++J^kH4PJbKlqJzOU=Puj_lA?mJEn zb{c92)D#pHG|rv<##uoD;-R3R_@k;aIC32cdklUQBc1JR6-qe=XB8BT6wZBf`tnUb z@rX*Lw%vYZr^LwSlL{QYnT74`#K^W?M-N7zIM+m2`?-3G-2q@tgC1Q-DK}d} z8L)pOx~&AIA1k{4fgeyD%^h)eZf8f?c5G({3U+{c$L~O(J8txU5EaZgy&1oU@H=R6 zBjg&_)!o#rZmy!WDOp7~ert7LTqbGszaZa_9)cP}??twC^=yxPVZ!7mC?mVOu1Ml8r`M!iLMsohDxHI5*9Q2&<-|a z;!|QavpV4KZ>G1}W?DU5@nV8(S=F%g^HE1L>+Q|uo*6FuY)V2{bAS$QsC=S59Up78 zU3K)nQogYw4Fh1MIR4^vCf*FY|9WUASDo)y*slFwsrKCzQVTOkzo+J9`~)6#-Bb?E z>ti}%WaFn$L-(;_TQ^Irq(3O#Dyo0feZdz?0mXZ**9jkpo7Qk zDl}m3ju!S&^^jfoS#LZ*sCho1p9$$7j1(=Gb@H`qWDRv+Od|!Hhnz&I8D>ebhHHJQ6_J| zoWN4@4Aw@7qn@$`liR>*zjv@z6%o+k2|Iw1TmG^iu<3642fqmM`5_PUFT>cCo}TI| z8vWwU15+1iGapoSVE9?=Zx4><-nxFnB?cHE zDAII&M5R4Ef3E(jp8I2c&i7n{RGrSWAdcUQZfmq8jkOLUJ14Dnp#F_gX`_6j6<8D0 zO~m&52Ks{r$XgSn{wBTmj$x4#^3X^?9QBX*I!J_ zLk$gJWQ2()!8t>Fq5Vo}1cGq>Rg_yPis%jy^0Guc9Hm5l`E&ZzK)KV4%r;tAxnD5S zq`S)ef(YW4QZVM5LwJCwUufppQw#o7!?Y~EwnvB`pUa}@Mg>itgsNtlf@zT Z~c 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 i{M}vuf7WJSFxao^=7C}=ocq?{n^N0=r2hcx!+4

Date: Fri, 16 Aug 2024 23:52:13 +0900 Subject: [PATCH 2/2] Add langs to builder --- Cargo.lock | 1 + bsky-sdk/Cargo.toml | 1 + bsky-sdk/src/feed/post.rs | 23 ++++++++++++++++------- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2166e826..4532d97b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,6 +290,7 @@ dependencies = [ "futures", "http 1.1.0", "ipld-core", + "langtag", "psl", "regex", "serde", diff --git a/bsky-sdk/Cargo.toml b/bsky-sdk/Cargo.toml index c14cc1a5..f80f6ad6 100644 --- a/bsky-sdk/Cargo.toml +++ b/bsky-sdk/Cargo.toml @@ -19,6 +19,7 @@ atrium-xrpc-client = { workspace = true, optional = true } chrono.workspace = true futures.workspace = true http.workspace = true +langtag.workspace = true psl = { version = "2.1.42", optional = true } regex.workspace = true serde = { workspace = true, features = ["derive"] } diff --git a/bsky-sdk/src/feed/post.rs b/bsky-sdk/src/feed/post.rs index 498295ce..c11bf1e1 100644 --- a/bsky-sdk/src/feed/post.rs +++ b/bsky-sdk/src/feed/post.rs @@ -22,6 +22,8 @@ pub enum BuilderError { Sdk(#[from] crate::error::Error), #[error(transparent)] Io(#[from] std::io::Error), + #[error("failed to parse lang: {0}")] + Lang(langtag::Error), } pub type Result = core::result::Result; @@ -124,6 +126,7 @@ pub struct Builder { inner: RecordBuilder, auto_detect_facets: bool, embed: Option, + langs: Option>, } impl Builder { @@ -132,6 +135,7 @@ impl Builder { inner: RecordBuilder::new(text), auto_detect_facets: true, embed: None, + langs: None, } } pub fn auto_detect_facets(mut self, value: bool) -> Self { @@ -166,10 +170,10 @@ impl Builder { self.inner = self.inner.labels(labels); self } - // pub fn langs(mut self, langs: Vec) -> Self { - // self.langs = Some(langs); - // self - // } + pub fn langs(mut self, langs: Vec>) -> Self { + self.langs = Some(langs.into_iter().map(|s| s.as_ref().into()).collect()); + self + } // pub fn reply(mut self, reply: ReplyRef) -> Self { // self.reply = Some(reply); // self @@ -225,12 +229,17 @@ impl Builder { images::MainData { images }.into(), )) } - // _ => { - // todo!() - // } }; self.inner = self.inner.embed(Union::Refs(refs)); } + if let Some(langs) = &self.langs { + self.inner = self.inner.langs( + langs + .iter() + .map(|s| s.parse().map_err(BuilderError::Lang)) + .collect::>()?, + ); + } if self.auto_detect_facets { if let Some(facets) = RichText::new_with_detect_facets(&self.inner.text) .await?