use std::{env, fs}; use atrium_api::{ app::bsky::feed::post::RecordData, com::atproto::repo::list_records::{OutputData, ParametersData}, types::{ TryFromUnknown, string::{AtIdentifier, Datetime}, }, }; use bsky_sdk::BskyAgent; use chrono::{Duration, Utc}; use clap::Parser; use serde::Deserialize; #[derive(Debug, Parser)] #[command(about = "Delete posts created by the account during the last hour")] struct Args { /// Print matching posts without deleting them. #[arg(long)] dry: bool, } #[derive(Deserialize)] struct Credentials { identifier: String, password: String, } #[tokio::main] async fn main() -> Result<(), String> { tracing_subscriber::fmt::init(); let args = Args::parse(); let credentials = load_credentials()?; let repo: AtIdentifier = credentials .identifier .parse() .map_err(|error| format!("invalid Bluesky identifier: {error}"))?; let cutoff = Datetime::new((Utc::now() - Duration::hours(1)).fixed_offset()); let agent = BskyAgent::builder() .build() .await .map_err(|error| format!("failed to create Bluesky client: {error}"))?; agent .login(credentials.identifier, credentials.password) .await .map_err(|error| format!("failed to log in to Bluesky: {error}"))?; let mut cursor = None; let mut posts = Vec::new(); let mut reached_cutoff = false; while !reached_cutoff { let output = agent .api .com .atproto .repo .list_records( ParametersData { collection: "app.bsky.feed.post".parse().unwrap(), cursor, limit: Some(100.try_into().unwrap()), repo: repo.clone(), // listRecords is newest-first by default. reverse: None, } .into(), ) .await .map_err(|error| format!("failed to list posts: {error}"))?; let OutputData { records, cursor: next_cursor, } = output.data; for record in records { let record = record.data; let post = RecordData::try_from_unknown(record.value) .map_err(|error| format!("failed to parse post {}: {error}", record.uri))?; if post.created_at < cutoff { reached_cutoff = true; break; } posts.push((record.uri, post.text)); } cursor = next_cursor; if cursor.is_none() { break; } } if posts.is_empty() { println!("No posts from the last hour."); return Ok(()); } for (uri, text) in &posts { if args.dry { println!("Would delete {uri}: {text}"); } else { tracing::info!(%uri, "deleting post"); agent .delete_record(uri) .await .map_err(|error| format!("failed to delete {uri}: {error}"))?; println!("Deleted {uri}: {text}"); } } if args.dry { println!("{} post(s) would be deleted.", posts.len()); } else { println!("Deleted {} post(s).", posts.len()); } Ok(()) } fn load_credentials() -> Result { let path = env::var("BSKY_CREDENTIALS_FILE") .map_err(|_| "BSKY_CREDENTIALS_FILE is not set".to_owned())?; let data = fs::read(&path).map_err(|error| format!("failed to read {path}: {error}"))?; serde_json::from_slice(&data).map_err(|error| format!("failed to parse {path}: {error}")) }