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}