bsky_cli/
runner.rs

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