A Rust CLI for publishing thought records. Designed to work with thought.stream.
1use anyhow::{Context, Result};
2use chrono::Utc;
3use reqwest::{Client as HttpClient, header::{HeaderMap, HeaderValue, AUTHORIZATION}};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::credentials::Credentials;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Session {
11 #[serde(rename = "accessJwt")]
12 pub access_jwt: String,
13 #[serde(rename = "refreshJwt")]
14 pub refresh_jwt: String,
15 pub handle: String,
16 pub did: String,
17}
18
19#[derive(Debug, Clone)]
20pub struct AtProtoClient {
21 http_client: HttpClient,
22 base_url: String,
23 session: Option<Session>,
24}
25
26#[derive(Debug, Serialize)]
27struct LoginRequest {
28 identifier: String,
29 password: String,
30}
31
32#[derive(Debug, Serialize)]
33struct CreateRecordRequest {
34 repo: String,
35 collection: String,
36 record: Value,
37}
38
39#[derive(Debug, Serialize)]
40struct BlipRecord {
41 #[serde(rename = "$type")]
42 record_type: String,
43 content: String,
44 #[serde(rename = "createdAt")]
45 created_at: String,
46}
47
48#[derive(Debug, Deserialize)]
49struct CreateRecordResponse {
50 uri: String,
51 cid: String,
52}
53
54impl AtProtoClient {
55 pub fn new(pds_uri: &str) -> Self {
56 Self {
57 http_client: HttpClient::new(),
58 base_url: pds_uri.to_string(),
59 session: None,
60 }
61 }
62
63 pub async fn login(&mut self, credentials: &Credentials) -> Result<()> {
64 let login_url = format!("{}/xrpc/com.atproto.server.createSession", self.base_url);
65
66 let request = LoginRequest {
67 identifier: credentials.username.clone(),
68 password: credentials.password.clone(),
69 };
70
71 let response = self.http_client
72 .post(&login_url)
73 .header("Content-Type", "application/json")
74 .json(&request)
75 .send()
76 .await
77 .context("Failed to send login request")?;
78
79 if !response.status().is_success() {
80 let status = response.status();
81 let error_text = response.text().await.unwrap_or_default();
82 anyhow::bail!("Login failed: {} - {}", status, error_text);
83 }
84
85 let session: Session = response
86 .json()
87 .await
88 .context("Failed to parse login response")?;
89
90 println!("Authenticated as: {}", session.handle);
91 self.session = Some(session);
92 Ok(())
93 }
94
95 pub async fn publish_blip(&self, content: &str) -> Result<String> {
96 let session = self.session.as_ref()
97 .context("Not authenticated. Please run 'thought login' first.")?;
98
99 let record = BlipRecord {
100 record_type: "stream.thought.blip".to_string(),
101 content: content.to_string(),
102 created_at: Utc::now().to_rfc3339().replace("+00:00", "Z"),
103 };
104
105 let request = CreateRecordRequest {
106 repo: session.did.clone(),
107 collection: "stream.thought.blip".to_string(),
108 record: serde_json::to_value(&record)
109 .context("Failed to serialize blip record")?,
110 };
111
112 let create_url = format!("{}/xrpc/com.atproto.repo.createRecord", self.base_url);
113
114 let mut headers = HeaderMap::new();
115 headers.insert(
116 AUTHORIZATION,
117 HeaderValue::from_str(&format!("Bearer {}", session.access_jwt))
118 .context("Invalid authorization header")?,
119 );
120 headers.insert(
121 "Content-Type",
122 HeaderValue::from_static("application/json"),
123 );
124
125 let response = self.http_client
126 .post(&create_url)
127 .headers(headers)
128 .json(&request)
129 .send()
130 .await
131 .context("Failed to send create record request")?;
132
133 if !response.status().is_success() {
134 let status = response.status();
135 let error_text = response.text().await.unwrap_or_default();
136 anyhow::bail!("Failed to publish blip: {} - {}", status, error_text);
137 }
138
139 let create_response: CreateRecordResponse = response
140 .json()
141 .await
142 .context("Failed to parse create record response")?;
143
144 Ok(create_response.uri)
145 }
146
147 pub fn is_authenticated(&self) -> bool {
148 self.session.is_some()
149 }
150
151 pub fn get_user_did(&self) -> Option<String> {
152 self.session.as_ref().map(|s| s.did.clone())
153 }
154}