use imageproc::{ geometric_transformations::{Interpolation, rotate}, image, image::{DynamicImage, ImageReader}, }; use jacquard::{ api::app_bsky::actor::profile::Profile, client::{ Agent, AgentSessionExt, AtpSession, MemoryCredentialSession, MemorySessionStore, credential_session::{CredentialSession, SessionKey}, }, cowstr::ToCowStr, identity::JacquardResolver, types::{aturi::AtUri, blob::MimeType}, }; use sha2::{Digest, Sha256}; use std::{ env, io::Cursor, process, time::{SystemTime, UNIX_EPOCH}, }; type AgentType = Agent, JacquardResolver>>; fn write_state(state_file: &str, angle: i32) { std::fs::write(state_file, angle.to_string()).unwrap_or_else(|e| { eprintln!("failed to write state file '{}': {}", state_file, e); // fine to not exit here like so what you'll do an extra upload who cares }); } fn read_state(state_file: &str) -> Option { let contents = std::fs::read_to_string(state_file).ok()?; let angle: i32 = contents.trim().parse().ok()?; Some(angle) } fn print_usage(executable: &str) { eprintln!("usage: {} [output]", executable); eprintln!("environment variables:"); eprintln!(" APP_PASSWORD"); eprintln!(" IDENTIFIER - your handle or did"); eprintln!(" BACKGROUND - rgba hex background color to use (default: 00000000)"); eprintln!(" STATE_FILE - path to the state file (default: /tmp/washing-machien)"); process::exit(1); } fn calculate_offset_from_did>(did: S) -> i32 { let mut hasher = Sha256::new(); hasher.update(did.as_ref().to_lowercase().as_bytes()); let digest = hasher.finalize(); let arr: [u8; 8] = digest[0..8].try_into().unwrap(); let v = u64::from_le_bytes(arr); (v % 360) as i32 } fn compute_angle>(did: S) -> i32 { const MS_PER_DAY: u128 = 86_400_000; let now_ms = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_else(|e| { eprintln!("warning: system time before epoch: {:?}", e); std::time::Duration::from_millis(0) }) .as_millis(); let ms_today = now_ms % MS_PER_DAY; let time_angle = ((ms_today * 360) / MS_PER_DAY) as i32; let did_offset = calculate_offset_from_did(did); let final_angle = (time_angle + did_offset) % 360; println!( "computed angle: time_angle={} did_offset={} final_angle={}", time_angle, did_offset, final_angle ); final_angle } fn rotate_image(image: DynamicImage, degrees: i32, bg_color: image::Rgba) -> image::RgbaImage { let width = image.width(); let height = image.height(); let radians = (degrees as f32).to_radians(); let rotated = rotate( &image.to_rgba8(), (width as f32 / 2.0, height as f32 / 2.0), radians, Interpolation::Bilinear, bg_color, ); rotated } async fn update_avatar(image: image::RgbaImage, agent: &AgentType, format: &image::ImageFormat) { let mut buf = Cursor::new(Vec::new()); let dyn_img = DynamicImage::ImageRgba8(image); dyn_img.write_to(&mut buf, *format).unwrap_or_else(|e| { eprintln!("failed to encode image into {:?}: {}", format, e); process::exit(1); }); let encoded_bytes = buf.into_inner(); let format_string = format!("{:?}", format); let mime_string = format!("image/{}", format_string.to_lowercase()); let mime = MimeType::new_owned(mime_string); let blob = agent .upload_blob(encoded_bytes, mime) .await .unwrap_or_else(|e| { eprintln!("failed to upload avatar blob: {}", e); process::exit(1); }); let did = agent.info().await.unwrap().0; let at_uri = AtUri::new_owned(format!("at://{}/app.bsky.actor.profile/self", did)).unwrap(); agent .update_record::(&at_uri, |profile| { profile.avatar = Some(blob.into()); }) .await .unwrap_or_else(|e| { eprintln!("failed to update profile: {}", e); process::exit(1); }); println!("successfully updated avatar"); } fn env(var: &str, default: Option<&str>, exe: &str) -> String { match env::var(var) { Ok(v) if !v.trim().is_empty() => v, _ => { if let Some(def) = default { return def.to_string(); } else { eprintln!("env variable {} not set or empty", var); print_usage(exe); process::exit(1); } } } } fn hex_to_rgba(hex: String) -> image::Rgba { let hex = hex.trim_start_matches('#'); let (r, g, b, a) = match hex.len() { 6 => ( u8::from_str_radix(&hex[0..2], 16).unwrap(), u8::from_str_radix(&hex[2..4], 16).unwrap(), u8::from_str_radix(&hex[4..6], 16).unwrap(), 255, ), 8 => ( u8::from_str_radix(&hex[0..2], 16).unwrap(), u8::from_str_radix(&hex[2..4], 16).unwrap(), u8::from_str_radix(&hex[4..6], 16).unwrap(), u8::from_str_radix(&hex[6..8], 16).unwrap(), ), _ => panic!("Invalid hex color"), }; image::Rgba([r, g, b, a]) } #[tokio::main] async fn main() { let mut args = env::args(); let executable = args.next().unwrap_or_else(|| "washing-machine".to_string()); let input = match args.next() { Some(i) => i, None => { print_usage(&executable); return; } }; let output = args.next(); let app_password = env("APP_PASSWORD", None, &executable); let identifier = env("IDENTIFIER", None, &executable); let bg_color = env("BACKGROUND", Some("00000000"), &executable); let state_file = env("STATE_FILE", Some("/tmp/washing-machien"), &executable); let (session, auth) = MemoryCredentialSession::authenticated( identifier.to_cowstr(), app_password.to_cowstr(), None, None, ) .await .unwrap_or_else(|e| { eprintln!("authentication failed: {}", e); process::exit(1); }); let agent: Agent<_> = Agent::from(session); println!("authenticated as {}, hi!", auth.handle); let input_path = input; let reader = ImageReader::open(&input_path).unwrap_or_else(|e| { eprintln!("failed to open '{}': {}", input_path, e); process::exit(1); }); let mime_type = reader.format().unwrap_or_else(|| { eprintln!("failed to determine format of '{}'", input_path); process::exit(1); }); let image = reader.decode().unwrap_or_else(|e| { eprintln!("failed to decode '{}': {}", input_path, e); process::exit(1); }); let angle = compute_angle(&auth.did); if let Some(prev) = read_state(&state_file) { if prev == angle { println!("profile picture is already at {} degrees", angle); return; } } let bg_color_rgba = hex_to_rgba(bg_color); let rotated = rotate_image(image, angle, bg_color_rgba); if let Some(out) = &output { rotated.save(out).unwrap_or_else(|e| { eprintln!("failed to save '{}': {}", out.to_string(), e); }); } update_avatar(rotated, &agent, &mime_type).await; write_state(&state_file, angle); }