1use crate::commands::Command;
2use crate::store::SimpleJsonFileSessionStore;
3use anyhow::{Context, Result};
4use atrium_api::agent::bluesky::{AtprotoServiceType, BSKY_CHAT_DID};
5use atrium_api::agent::{store::SessionStore, AtpAgent};
6use atrium_api::records::{KnownRecord, Record};
7use atrium_api::types::string::{AtIdentifier, Datetime, Handle};
8use atrium_api::types::LimitedNonZeroU8;
9use atrium_xrpc_client::reqwest::ReqwestClient;
10use serde::Serialize;
11use std::ffi::OsStr;
12use std::path::PathBuf;
13use tokio::fs::{create_dir_all, File};
14use tokio::io::AsyncReadExt;
15
16pub struct Runner {
17 agent: AtpAgent<SimpleJsonFileSessionStore, ReqwestClient>,
18 limit: LimitedNonZeroU8<100>,
19 debug: bool,
20 session_path: PathBuf,
21 handle: Option<Handle>,
22}
23
24impl Runner {
25 pub async fn new(pds_host: String, limit: LimitedNonZeroU8<100>, debug: bool) -> Result<Self> {
26 let config_dir = dirs::config_dir()
27 .with_context(|| format!("No config dir: {:?}", dirs::config_dir()))?;
28 let dir = config_dir.join("atrium-cli");
29 create_dir_all(&dir).await?;
30 let session_path = dir.join("session.json");
31 let store = SimpleJsonFileSessionStore::new(session_path.clone());
32 let session = store.get_session().await;
33 let handle = session.as_ref().map(|s| s.handle.clone());
34 let agent = AtpAgent::new(ReqwestClient::new(pds_host), store);
35 if let Some(s) = &session {
36 agent.resume_session(s.clone()).await?;
37 }
38 Ok(Self {
39 agent,
40 limit,
41 debug,
42 session_path,
43 handle,
44 })
45 }
46 pub async fn run(&self, command: Command) -> Result<()> {
47 let limit = self.limit;
48 match command {
49 Command::Login(args) => {
50 self.agent.login(args.identifier, args.password).await?;
51 println!("Login successful! Saved session to {:?}", self.session_path);
52 Ok(())
53 }
54 Command::GetTimeline => self.print(
55 &self
56 .agent
57 .api
58 .app
59 .bsky
60 .feed
61 .get_timeline(
62 atrium_api::app::bsky::feed::get_timeline::ParametersData {
63 algorithm: None,
64 cursor: None,
65 limit: Some(limit),
66 }
67 .into(),
68 )
69 .await?,
70 ),
71 Command::GetAuthorFeed(args) => self.print(
72 &self
73 .agent
74 .api
75 .app
76 .bsky
77 .feed
78 .get_author_feed(
79 atrium_api::app::bsky::feed::get_author_feed::ParametersData {
80 actor: args
81 .actor
82 .or(self.handle.clone().map(AtIdentifier::Handle))
83 .with_context(|| "Not logged in")?,
84 cursor: None,
85 filter: None,
86 limit: Some(limit),
87 }
88 .into(),
89 )
90 .await?,
91 ),
92 Command::GetLikes(args) => self.print(
93 &self
94 .agent
95 .api
96 .app
97 .bsky
98 .feed
99 .get_likes(
100 atrium_api::app::bsky::feed::get_likes::ParametersData {
101 cid: None,
102 cursor: None,
103 limit: Some(limit),
104 uri: args.uri.to_string(),
105 }
106 .into(),
107 )
108 .await?,
109 ),
110 Command::GetRepostedBy(args) => self.print(
111 &self
112 .agent
113 .api
114 .app
115 .bsky
116 .feed
117 .get_reposted_by(
118 atrium_api::app::bsky::feed::get_reposted_by::ParametersData {
119 cid: None,
120 cursor: None,
121 limit: Some(limit),
122 uri: args.uri.to_string(),
123 }
124 .into(),
125 )
126 .await?,
127 ),
128 Command::GetActorFeeds(args) => self.print(
129 &self
130 .agent
131 .api
132 .app
133 .bsky
134 .feed
135 .get_actor_feeds(
136 atrium_api::app::bsky::feed::get_actor_feeds::ParametersData {
137 actor: args
138 .actor
139 .or(self.handle.clone().map(AtIdentifier::Handle))
140 .with_context(|| "Not logged in")?,
141 cursor: None,
142 limit: Some(limit),
143 }
144 .into(),
145 )
146 .await?,
147 ),
148 Command::GetFeed(args) => self.print(
149 &self
150 .agent
151 .api
152 .app
153 .bsky
154 .feed
155 .get_feed(
156 atrium_api::app::bsky::feed::get_feed::ParametersData {
157 cursor: None,
158 feed: args.uri.to_string(),
159 limit: Some(limit),
160 }
161 .into(),
162 )
163 .await?,
164 ),
165 Command::GetListFeed(args) => self.print(
166 &self
167 .agent
168 .api
169 .app
170 .bsky
171 .feed
172 .get_list_feed(
173 atrium_api::app::bsky::feed::get_list_feed::ParametersData {
174 cursor: None,
175 limit: Some(limit),
176 list: args.uri.to_string(),
177 }
178 .into(),
179 )
180 .await?,
181 ),
182 Command::GetFollows(args) => self.print(
183 &self
184 .agent
185 .api
186 .app
187 .bsky
188 .graph
189 .get_follows(
190 atrium_api::app::bsky::graph::get_follows::ParametersData {
191 actor: args
192 .actor
193 .or(self.handle.clone().map(AtIdentifier::Handle))
194 .with_context(|| "Not logged in")?,
195 cursor: None,
196 limit: Some(limit),
197 }
198 .into(),
199 )
200 .await?,
201 ),
202 Command::GetFollowers(args) => self.print(
203 &self
204 .agent
205 .api
206 .app
207 .bsky
208 .graph
209 .get_followers(
210 atrium_api::app::bsky::graph::get_followers::ParametersData {
211 actor: args
212 .actor
213 .or(self.handle.clone().map(AtIdentifier::Handle))
214 .with_context(|| "Not logged in")?,
215 cursor: None,
216 limit: Some(limit),
217 }
218 .into(),
219 )
220 .await?,
221 ),
222 Command::GetLists(args) => self.print(
223 &self
224 .agent
225 .api
226 .app
227 .bsky
228 .graph
229 .get_lists(
230 atrium_api::app::bsky::graph::get_lists::ParametersData {
231 actor: args
232 .actor
233 .or(self.handle.clone().map(AtIdentifier::Handle))
234 .with_context(|| "Not logged in")?,
235 cursor: None,
236 limit: Some(limit),
237 }
238 .into(),
239 )
240 .await?,
241 ),
242 Command::GetList(args) => self.print(
243 &self
244 .agent
245 .api
246 .app
247 .bsky
248 .graph
249 .get_list(
250 atrium_api::app::bsky::graph::get_list::ParametersData {
251 cursor: None,
252 limit: Some(limit),
253 list: args.uri.to_string(),
254 }
255 .into(),
256 )
257 .await?,
258 ),
259 Command::GetProfile(args) => self.print(
260 &self
261 .agent
262 .api
263 .app
264 .bsky
265 .actor
266 .get_profile(
267 atrium_api::app::bsky::actor::get_profile::ParametersData {
268 actor: args
269 .actor
270 .or(self.handle.clone().map(AtIdentifier::Handle))
271 .with_context(|| "Not logged in")?,
272 }
273 .into(),
274 )
275 .await?,
276 ),
277 Command::GetPreferences => self.print(
278 &self
279 .agent
280 .api
281 .app
282 .bsky
283 .actor
284 .get_preferences(
285 atrium_api::app::bsky::actor::get_preferences::ParametersData {}.into(),
286 )
287 .await?,
288 ),
289 Command::ListNotifications => self.print(
290 &self
291 .agent
292 .api
293 .app
294 .bsky
295 .notification
296 .list_notifications(
297 atrium_api::app::bsky::notification::list_notifications::ParametersData {
298 cursor: None,
299 limit: Some(limit),
300 seen_at: None,
301 }
302 .into(),
303 )
304 .await?,
305 ),
306 Command::ListConvos => self.print(
307 &self
308 .agent
309 .api_with_proxy(
310 BSKY_CHAT_DID.parse().expect("valid DID"),
311 AtprotoServiceType::BskyChat,
312 )
313 .chat
314 .bsky
315 .convo
316 .list_convos(
317 atrium_api::chat::bsky::convo::list_convos::ParametersData {
318 cursor: None,
319 limit: Some(limit),
320 }
321 .into(),
322 )
323 .await?,
324 ),
325 Command::SendConvoMessage(args) => {
326 let did = match args.actor {
327 AtIdentifier::Handle(handle) => self
328 .agent
329 .api
330 .com
331 .atproto
332 .identity
333 .resolve_handle(
334 atrium_api::com::atproto::identity::resolve_handle::ParametersData {
335 handle: handle.clone(),
336 }
337 .into(),
338 )
339 .await?
340 .data
341 .did,
342 AtIdentifier::Did(did) => did,
343 };
344 let chat = &self
345 .agent
346 .api_with_proxy(
347 BSKY_CHAT_DID.parse().expect("valid DID"),
348 AtprotoServiceType::BskyChat,
349 )
350 .chat;
351 let convo = chat
352 .bsky
353 .convo
354 .get_convo_for_members(
355 atrium_api::chat::bsky::convo::get_convo_for_members::ParametersData {
356 members: vec![did],
357 }
358 .into(),
359 )
360 .await?;
361 self.print(
362 &chat
363 .bsky
364 .convo
365 .send_message(
366 atrium_api::chat::bsky::convo::send_message::InputData {
367 convo_id: convo.data.convo.data.id,
368 message: atrium_api::chat::bsky::convo::defs::MessageInputData {
369 embed: None,
370 facets: None,
371 text: args.text,
372 }
373 .into(),
374 }
375 .into(),
376 )
377 .await?,
378 )
379 }
380 Command::CreatePost(args) => {
381 let mut images = Vec::new();
382 for image in &args.images {
383 if let Ok(mut file) = File::open(image).await {
384 let mut buf = Vec::new();
385 file.read_to_end(&mut buf).await.expect("read image file");
386 let output = self
387 .agent
388 .api
389 .com
390 .atproto
391 .repo
392 .upload_blob(buf)
393 .await
394 .expect("upload blob");
395 images.push(
396 atrium_api::app::bsky::embed::images::ImageData {
397 alt: image
398 .file_name()
399 .map(OsStr::to_string_lossy)
400 .unwrap_or_default()
401 .into(),
402 aspect_ratio: None,
403 image: output.data.blob,
404 }
405 .into(),
406 )
407 }
408 }
409 let embed = Some(atrium_api::types::Union::Refs(
410 atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedImagesMain(
411 Box::new(atrium_api::app::bsky::embed::images::MainData { images }.into()),
412 ),
413 ));
414 self.print(
415 &self
416 .agent
417 .api
418 .com
419 .atproto
420 .repo
421 .create_record(
422 atrium_api::com::atproto::repo::create_record::InputData {
423 collection: "app.bsky.feed.post".parse().expect("valid"),
424 record: Record::Known(KnownRecord::AppBskyFeedPost(Box::new(
425 atrium_api::app::bsky::feed::post::RecordData {
426 created_at: Datetime::now(),
427 embed,
428 entities: None,
429 facets: None,
430 labels: None,
431 langs: None,
432 reply: None,
433 tags: None,
434 text: args.text,
435 }
436 .into(),
437 ))),
438 repo: self.handle.clone().with_context(|| "Not logged in")?.into(),
439 rkey: None,
440 swap_commit: None,
441 validate: None,
442 }
443 .into(),
444 )
445 .await?,
446 )
447 }
448 Command::DeletePost(args) => self.print(
449 &self
450 .agent
451 .api
452 .com
453 .atproto
454 .repo
455 .delete_record(
456 atrium_api::com::atproto::repo::delete_record::InputData {
457 collection: "app.bsky.feed.post".parse().expect("valid"),
458 repo: self.handle.clone().with_context(|| "Not logged in")?.into(),
459 rkey: args.uri.rkey,
460 swap_commit: None,
461 swap_record: None,
462 }
463 .into(),
464 )
465 .await?,
466 ),
467 }
468 }
469 fn print<T: std::fmt::Debug + Serialize>(&self, result: &T) -> Result<()> {
470 if self.debug {
471 println!("{:#?}", result);
472 } else {
473 println!("{}", serde_json::to_string_pretty(result)?);
474 }
475 Ok(())
476 }
477}