A better Rust ATProto crate
1use clap::Parser; 2use jacquard::CowStr; 3use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 4use jacquard::client::{Agent, FileAuthStore}; 5use jacquard::oauth::atproto::AtprotoClientMetadata; 6use jacquard::oauth::client::OAuthClient; 7#[cfg(feature = "loopback")] 8use jacquard::oauth::loopback::LoopbackConfig; 9use jacquard::xrpc::XrpcClient; 10#[cfg(not(feature = "loopback"))] 11use jacquard_oauth::types::AuthorizeOptions; 12use miette::IntoDiagnostic; 13 14#[derive(Parser, Debug)] 15#[command(author, version, about = "Jacquard - OAuth (DPoP) loopback demo")] 16struct Args { 17 /// Handle (e.g., alice.bsky.social), DID, or PDS URL 18 input: CowStr<'static>, 19 20 /// Path to auth store file (will be created if missing) 21 #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")] 22 store: String, 23} 24 25#[tokio::main] 26async fn main() -> miette::Result<()> { 27 let args = Args::parse(); 28 29 // File-backed auth store shared by OAuthClient and session registry 30 let store = FileAuthStore::new(&args.store); 31 let client_data = jacquard_oauth::session::ClientData { 32 keyset: None, 33 // Default sets normal localhost redirect URIs and "atproto transition:generic" scopes. 34 // The localhost helper will ensure you have at least "atproto" and will fix urls 35 config: AtprotoClientMetadata::default_localhost(), 36 }; 37 38 // Build an OAuth client (this is reusable, and can create multiple sessions) 39 let oauth = OAuthClient::new(store, client_data); 40 41 #[cfg(feature = "loopback")] 42 // Authenticate with a PDS, using a loopback server to handle the callback flow 43 let session = oauth 44 .login_with_local_server( 45 args.input.clone(), 46 Default::default(), 47 LoopbackConfig::default(), 48 ) 49 .await?; 50 51 #[cfg(not(feature = "loopback"))] 52 let session = { 53 use std::io::{BufRead, Write, stdin, stdout}; 54 55 let auth_url = oauth 56 .start_auth(args.input, AuthorizeOptions::default()) 57 .await?; 58 59 println!("To authenticate with your PDS, visit:\n{}\n", auth_url); 60 print!("\nPaste the callback url here:"); 61 stdout().lock().flush().into_diagnostic()?; 62 let mut url = String::new(); 63 stdin().lock().read_line(&mut url).into_diagnostic()?; 64 65 let uri = url.trim().parse::<http::Uri>().into_diagnostic()?; 66 let params = 67 serde_html_form::from_str(uri.query().ok_or(miette::miette!("invalid callback url"))?) 68 .into_diagnostic()?; 69 oauth.callback(params).await? 70 }; 71 72 // Wrap in Agent and fetch the timeline 73 let agent: Agent<_> = Agent::from(session); 74 let output = agent.send(GetTimeline::new().limit(5).build()).await?; 75 let timeline = output.into_output()?; 76 for (i, post) in timeline.feed.iter().enumerate() { 77 println!("\n{}. by {}", i + 1, post.post.author.handle); 78 println!( 79 " {}", 80 serde_json::to_string_pretty(&post.post.record).into_diagnostic()? 81 ); 82 } 83 84 Ok(()) 85}