A better Rust ATProto crate
at main 6.4 kB view raw
1use clap::Parser; 2use jacquard::CowStr; 3use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 4use jacquard::api::app_bsky::feed::post::Post; 5use jacquard::api::app_bsky::labeler::get_services::GetServicesOutput; 6use jacquard::client::{Agent, FileAuthStore}; 7use jacquard::cowstr::ToCowStr; 8use jacquard::from_data; 9use jacquard::moderation::{Blur, Moderateable, ModerationPrefs, fetch_labeler_defs}; 10use jacquard::oauth::atproto::AtprotoClientMetadata; 11use jacquard::oauth::client::OAuthClient; 12use jacquard::oauth::loopback::LoopbackConfig; 13use jacquard::xrpc::{CallOptions, XrpcClient}; 14use jacquard_api::app_bsky::feed::{ReplyRefParent, ReplyRefRoot}; 15use jacquard_api::app_bsky::labeler::get_services::GetServicesOutputViewsItem; 16 17// To save having to fetch prefs, etc., we're borrowing some from our test cases. 18const LABELER_SERVICES_JSON: &str = 19 include_str!("../crates/jacquard/src/moderation/labeler_services.json"); 20 21#[derive(Parser, Debug)] 22#[command( 23 author, 24 version, 25 about = "Fetch timeline with moderation labels applied" 26)] 27struct Args { 28 /// Handle (e.g., alice.bsky.social), DID, or PDS URL 29 input: CowStr<'static>, 30 31 /// Path to auth store file (will be created if missing) 32 #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")] 33 store: String, 34 35 /// Number of posts to fetch 36 #[arg(short, long, default_value = "50")] 37 limit: i64, 38} 39 40#[tokio::main] 41async fn main() -> miette::Result<()> { 42 let args = Args::parse(); 43 44 // Extract labeler DIDs from the static JSON (used for testing) 45 let services: GetServicesOutput<'static> = 46 serde_json::from_str(LABELER_SERVICES_JSON).expect("failed to parse labeler services"); 47 48 let mut accepted_labelers = Vec::new(); 49 50 for view in services.views { 51 if let GetServicesOutputViewsItem::LabelerViewDetailed(detailed) = view { 52 accepted_labelers.push(detailed.creator.did.clone()); 53 } 54 } 55 56 println!( 57 "Fetching live definitions for {} labelers...", 58 accepted_labelers.len() 59 ); 60 61 // OAuth login 62 let store = FileAuthStore::new(&args.store); 63 let client_data = jacquard_oauth::session::ClientData { 64 keyset: None, 65 config: AtprotoClientMetadata::default_localhost(), 66 }; 67 68 let oauth = OAuthClient::new(store, client_data); 69 let session = oauth 70 .login_with_local_server( 71 args.input.clone(), 72 Default::default(), 73 LoopbackConfig::default(), 74 ) 75 .await?; 76 77 let agent: Agent<_> = Agent::from(session); 78 79 // Fetch live labeler definitions from the network 80 let defs = fetch_labeler_defs(&agent, accepted_labelers.clone()).await?; 81 82 println!("Loaded definitions for {} labelers\n", defs.defs.len()); 83 84 // Fetch timeline with labelers enabled via CallOptions 85 let mut opts = CallOptions::default(); 86 opts.atproto_accept_labelers = Some( 87 accepted_labelers 88 .iter() 89 .map(|did| did.to_cowstr()) 90 .collect(), 91 ); 92 let request = GetTimeline::new().limit(args.limit).build(); 93 94 println!("\nFetching timeline with {} posts...\n", args.limit); 95 96 let response = agent.send_with_opts(request, opts).await?; 97 let timeline = response.into_output()?; 98 99 // Apply moderation preferences (default: no adult content) 100 let prefs = ModerationPrefs::default(); 101 102 let mut filtered = 0; 103 let mut warned = 0; 104 let mut clean = 0; 105 106 for feed_post in timeline.feed.iter() { 107 let post = &feed_post.post; 108 109 // Use Moderateable trait to get moderation decisions for all parts 110 // (post, author, reply chain) 111 let decisions = feed_post.moderate_all(&prefs, &defs, &accepted_labelers); 112 113 // Determine overall status from all decisions 114 if decisions.iter().any(|(_, d)| d.filter) { 115 filtered += 1; 116 } else if decisions 117 .iter() 118 .any(|(_, d)| d.blur != Blur::None || d.alert) 119 { 120 warned += 1; 121 } else { 122 clean += 1; 123 } 124 125 let text = from_data::<Post>(&post.record) 126 .inspect_err(|e| println!("error: {e}")) 127 .ok() 128 .map(|p| p.text.to_string()) 129 .unwrap_or_else(|| "<no text>".to_string()); 130 131 if let Some(reply) = &feed_post.reply { 132 if let ReplyRefParent::PostView(parent) = &reply.parent { 133 if let ReplyRefRoot::PostView(root) = &reply.root { 134 if root.uri != parent.uri { 135 let root_text = from_data::<Post>(&root.record) 136 .ok() 137 .map(|p| p.text.to_string()) 138 .unwrap_or_else(|| "<no text>".to_string()); 139 println!("@{}:\n{}", root.author.handle, root_text); 140 } 141 } 142 let parent_text = from_data::<Post>(&parent.record) 143 .ok() 144 .map(|p| p.text.to_string()) 145 .unwrap_or_else(|| "<no text>".to_string()); 146 println!("@{}:\n{}", parent.author.handle, parent_text); 147 } 148 } 149 println!("@{}:\n{}", post.author.handle, text); 150 151 // Show details for any part with moderation causes 152 for (tag, decision) in decisions.iter() { 153 if !decision.causes.is_empty() { 154 println!( 155 " {}: {:?}", 156 tag, 157 decision 158 .causes 159 .iter() 160 .map(|c| c.label.as_str()) 161 .collect::<Vec<_>>() 162 ); 163 if decision.filter { 164 println!(" → Would be hidden"); 165 } else if decision.blur != Blur::None { 166 println!(" → Would be blurred ({:?})", decision.blur); 167 } 168 if decision.alert { 169 println!(" → Alert-level warning"); 170 } 171 if decision.no_override { 172 println!(" → User cannot override"); 173 } 174 } 175 } 176 } 177 178 println!("\n--- Summary ---"); 179 println!("Total posts: {}", timeline.feed.len()); 180 println!("Clean: {}", clean); 181 println!("Warned: {}", warned); 182 println!("Filtered: {}", filtered); 183 184 Ok(()) 185}