use anyhow::{Context, Result}; use chrono::Utc; use reqwest::{Client as HttpClient, header::{HeaderMap, HeaderValue, AUTHORIZATION}}; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::credentials::Credentials; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { #[serde(rename = "accessJwt")] pub access_jwt: String, #[serde(rename = "refreshJwt")] pub refresh_jwt: String, pub handle: String, pub did: String, } #[derive(Debug, Clone)] pub struct AtProtoClient { http_client: HttpClient, base_url: String, session: Option, } #[derive(Debug, Serialize)] struct LoginRequest { identifier: String, password: String, } #[derive(Debug, Serialize)] struct CreateRecordRequest { repo: String, collection: String, record: Value, } #[derive(Debug, Serialize)] struct BlipRecord { #[serde(rename = "$type")] record_type: String, content: String, #[serde(rename = "createdAt")] created_at: String, } #[derive(Debug, Deserialize)] struct CreateRecordResponse { uri: String, cid: String, } impl AtProtoClient { pub fn new(pds_uri: &str) -> Self { Self { http_client: HttpClient::new(), base_url: pds_uri.to_string(), session: None, } } pub async fn login(&mut self, credentials: &Credentials) -> Result<()> { let login_url = format!("{}/xrpc/com.atproto.server.createSession", self.base_url); let request = LoginRequest { identifier: credentials.username.clone(), password: credentials.password.clone(), }; let response = self.http_client .post(&login_url) .header("Content-Type", "application/json") .json(&request) .send() .await .context("Failed to send login request")?; if !response.status().is_success() { let status = response.status(); let error_text = response.text().await.unwrap_or_default(); anyhow::bail!("Login failed: {} - {}", status, error_text); } let session: Session = response .json() .await .context("Failed to parse login response")?; println!("Authenticated as: {}", session.handle); self.session = Some(session); Ok(()) } pub async fn publish_blip(&self, content: &str) -> Result { let session = self.session.as_ref() .context("Not authenticated. Please run 'thought login' first.")?; let record = BlipRecord { record_type: "stream.thought.blip".to_string(), content: content.to_string(), created_at: Utc::now().to_rfc3339().replace("+00:00", "Z"), }; let request = CreateRecordRequest { repo: session.did.clone(), collection: "stream.thought.blip".to_string(), record: serde_json::to_value(&record) .context("Failed to serialize blip record")?, }; let create_url = format!("{}/xrpc/com.atproto.repo.createRecord", self.base_url); let mut headers = HeaderMap::new(); headers.insert( AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {}", session.access_jwt)) .context("Invalid authorization header")?, ); headers.insert( "Content-Type", HeaderValue::from_static("application/json"), ); let response = self.http_client .post(&create_url) .headers(headers) .json(&request) .send() .await .context("Failed to send create record request")?; if !response.status().is_success() { let status = response.status(); let error_text = response.text().await.unwrap_or_default(); anyhow::bail!("Failed to publish blip: {} - {}", status, error_text); } let create_response: CreateRecordResponse = response .json() .await .context("Failed to parse create record response")?; Ok(create_response.uri) } pub fn is_authenticated(&self) -> bool { self.session.is_some() } pub fn get_user_did(&self) -> Option { self.session.as_ref().map(|s| s.did.clone()) } }