small bsky embedder @ boobsky.app - kinda mid but works - mirror of git.fomx.gay/rooot/embedthing

feat: implement author and text embedding also adds initial oembed impl

Signed-off-by: rooot <hey@rooot.gay>

+103 -25
+1
Cargo.lock
··· 422 422 "serde", 423 423 "serde_json", 424 424 "thiserror", 425 + "url", 425 426 ] 426 427 427 428 [[package]]
+1
Cargo.toml
··· 13 13 thiserror = "1.0.65" 14 14 serde = "1.0.213" 15 15 serde_json = "1.0.132" 16 + url = "2.5.2"
+12
src/api.rs
··· 1 + use rocket::get; 2 + 3 + #[get("/api/v1/oembed?<desc>")] 4 + pub(crate) fn oembed(desc: String) -> String { 5 + serde_json::to_string(&serde_json::json!({ 6 + "author_name": desc, 7 + // needed for oembed 8 + "type":"link", 9 + "version":"1.0" 10 + })) 11 + .unwrap() 12 + }
+30 -6
src/bsky.rs
··· 1 - use crate::meow::{EmbedEmbed, EmbedThingy, GenericError, MissingElementError}; 1 + use crate::meow::{EmbedAuthor, EmbedMedia, EmbedThingy, GenericError, MissingElementError}; 2 2 use crate::ManagedBskyAgent; 3 3 use bsky_sdk::api::app::bsky::embed::images::ImageData; 4 4 use bsky_sdk::api::app::bsky::embed::video; ··· 58 58 59 59 let data = thread.post.data.clone(); 60 60 61 + dbg!(&data); 62 + 63 + let author = { 64 + let profile_view = data.author.clone(); 65 + let handle = profile_view.handle.clone().to_string(); 66 + EmbedAuthor { 67 + name: profile_view.display_name.clone().unwrap_or("".to_string()), 68 + profile_url: format!("https://bsky.social/profile/{}", handle), 69 + handle, 70 + avatar_url: profile_view.avatar.clone() 71 + } 72 + }; 73 + 61 74 if let Unknown::Object(record) = data.record { 62 75 if ipld_to_string(record.get_key_value("$type").ok_or(MissingElementError("$type".into()))?.1.deref()) == Ok("app.bsky.feed.post".to_string()) { 63 76 println!("this is a post"); 64 77 65 - let text = ipld_to_string(record.get_key_value("text").ok_or(MissingElementError("text".into()))?.1.deref()); 78 + let text = ipld_to_string(record.get_key_value("text").ok_or(MissingElementError("text".into()))?.1.deref()).ok(); 66 79 println!("post text: {:?}", text); 67 80 81 + // todo: format text into markdown so we can embed it nicely 82 + // for now we'll just return the text as is 83 + /*let text = { 84 + if let Some(ref text) = text { 85 + let rich = RichText::new_with_detect_facets(text).await?; 86 + Some(rich.text) 87 + } else { 88 + None 89 + } 90 + };*/ 91 + 68 92 let embeds = record.get_key_value("embed").ok_or(MissingElementError("embed".into()))?.1; 69 93 //println!("post embed(s): {:?}", embeds); 70 94 ··· 147 171 println!("blob: {:?}", blob); 148 172 let url = format!("https://bsky.social/xrpc/com.atproto.sync.getBlob?did={}&cid={}", did, blob.r#ref.0); 149 173 if blob.mime_type.starts_with("image/") { 150 - embed_embeds.push(EmbedEmbed::Image(url)); 174 + embed_embeds.push(EmbedMedia::Image(url)); 151 175 } else { 152 - embed_embeds.push(EmbedEmbed::Video(url)); 176 + embed_embeds.push(EmbedMedia::Video(url)); 153 177 } 154 178 } 155 179 156 180 println!("embed embeds aaa: {:?}", &embed_embeds); 157 181 158 182 return Ok(EmbedThingy { 159 - author: None, 160 - text: text.ok(), 183 + author: Some(author), 184 + text, 161 185 embeds: embed_embeds, 162 186 }); 163 187 }
+2 -1
src/main.rs
··· 1 + mod api; 1 2 mod bsky; 2 3 mod meow; 3 4 mod srv; ··· 46 47 .merge(("address", Ipv4Addr::from([0, 0, 0, 0]))); 47 48 48 49 let _rocket = rocket::build() 49 - .mount("/", routes![srv::version, bsky::profile_post,]) 50 + .mount("/", routes![srv::version, bsky::profile_post, api::oembed]) 50 51 .manage(ManagedBskyAgent { agent }) 51 52 .configure(figment) 52 53 .launch()
+57 -18
src/meow.rs
··· 3 3 use rocket::{response, Request, Response}; 4 4 use serde::ser::SerializeMap; 5 5 use serde::Serialize; 6 - use std::str::FromStr; 7 6 use thiserror::Error; 7 + use url::Url; 8 8 9 9 #[derive(Debug)] 10 10 pub struct EmbedAuthor { 11 11 pub name: String, 12 12 pub handle: String, 13 13 pub profile_url: String, 14 - pub avatar_url: String, 14 + pub avatar_url: Option<String>, 15 15 } 16 16 17 - // banger name 18 17 #[derive(Debug)] 19 - pub enum EmbedEmbed { 18 + pub enum EmbedMedia { 20 19 Video(String), 21 20 Image(String), 22 21 } ··· 25 24 pub struct EmbedThingy { 26 25 pub author: Option<EmbedAuthor>, 27 26 pub text: Option<String>, 28 - pub embeds: Vec<EmbedEmbed>, 27 + pub embeds: Vec<EmbedMedia>, 29 28 } 30 29 31 30 impl<'r, 'o: 'r> Responder<'r, 'o> for EmbedThingy { ··· 33 32 let mut html = String::new(); 34 33 html.push_str("<!DOCTYPE html>\n<html>\n<head>\n"); 35 34 html.push_str("<meta charset=\"UTF-8\">\n"); 36 - html.push_str("<title>boobs</title>\n"); 37 - html.push_str("<meta property=\"og:title\" content=\"boobs\">\n"); 38 35 html.push_str("<meta property=\"theme-color\" content=\"#f5c2e7\">\n"); 39 36 37 + if let Some(ref text) = self.text { 38 + html.push_str(&format!( 39 + r#"<meta property="og:description" content="{}">"#, 40 + text 41 + )); 42 + html.push('\n'); 43 + } 44 + 45 + // important: if there's no author, we will lack og:title 46 + // discord will then, FOR VIDEOS ONLY, ignore everything and just embed the video 47 + if let Some(author) = self.author { 48 + let author_title = format!("{} (@{})", author.name, author.handle); 49 + html.push_str(&format!( 50 + r#"<meta property="og:title" content="{}">"#, 51 + author_title 52 + )); 53 + html.push('\n'); 54 + //html.push_str(&format!(r#"<meta property="" content="{}">"#, author.profile_url)); 55 + } 56 + 40 57 if !self.embeds.is_empty() { 41 58 for embed in self.embeds { 42 59 match embed { 43 - EmbedEmbed::Image(url) => { 60 + EmbedMedia::Image(url) => { 44 61 println!("hehe image ! {}", url); 45 62 html.push_str(&format!(r#"<meta property="og:image" content="{}">"#, url)); 46 63 html.push('\n'); 47 64 } 48 - EmbedEmbed::Video(url) => { 49 - html.push_str(r#"<meta property="og:type" content="video.other">"#); 50 - html.push('\n'); 65 + EmbedMedia::Video(url) => { 51 66 html.push_str(&format!(r#"<meta property="og:video" content="{}">"#, url)); 52 67 html.push('\n'); 53 68 html.push_str(&format!( ··· 60 75 url 61 76 )); 62 77 html.push('\n'); 78 + 63 79 // THIS IS NEEDED FOR DISCORD !! 64 80 // todo: maybe don't hardcode video/mp4 as content type for all videos? 65 - html.push_str(r#"<meta property="og:video:type" content="video/mp4">"#) 81 + html.push_str(r#"<meta property="og:video:type" content="video/mp4">"#); 82 + html.push('\n'); 83 + 84 + html.push_str(&format!( 85 + r#"<meta property="twitter:player:stream" content="{}">"#, 86 + url 87 + )); 88 + html.push('\n'); 89 + 90 + html.push_str(r#"<meta property="twitter:card" content="player">"#); 91 + html.push('\n'); 92 + 93 + // push oembed - we only do this for videos bcs discord is stupid 94 + // todo: use rocket request guard to grab the Host header instead of hardcoding the url 95 + let mut oembed_url = 96 + Url::parse("https://dev.boobsky.app/api/v1/oembed").unwrap(); 97 + if let Some(ref text) = self.text { 98 + oembed_url.set_query(Some(&format!("desc={}", text))); 99 + } else { 100 + oembed_url.set_query(Some(&format!("desc={}", "meow"))); 101 + } 102 + 103 + html.push_str(&format!( 104 + r#"<link rel="alternate" type="application/json+oembed" href="{}" />"#, 105 + oembed_url.as_str() 106 + )); 66 107 } 67 108 } 68 109 } 69 110 } 70 - /* 71 - <meta property="og:type" content="video.other"> 72 - <meta property="og:video" content=""> 73 - <meta property="og:video:url" content=""> 74 - <meta property="og:video:secure_url" content=""> 75 - */ 76 111 77 112 html.push_str("</head>\n<body>meow</body>\n</html>"); 78 113 Ok(Response::build() ··· 115 150 116 151 #[error("invalid cid")] 117 152 InvalidCID(#[from] ipld_core::cid::Error), 153 + 154 + #[error("bsky sdk error")] 155 + Bsky(#[from] bsky_sdk::Error), 118 156 } 119 157 120 158 impl Serialize for GenericError { ··· 128 166 GenericError::BlockedBy(_) => self.to_string(), 129 167 GenericError::GetBlob(e) => e.to_string(), 130 168 GenericError::InvalidCID(e) => e.to_string(), 169 + GenericError::Bsky(e) => e.to_string(), 131 170 }; 132 171 let mut map = serializer.serialize_map(Some(2))?; 133 172 map.serialize_entry("error", &format!("{}: {}", self, &error_message))?;