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};
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                        }
234                        .into(),
235                    )
236                    .await?,
237            ),
238            Command::GetList(args) => self.print(
239                &self
240                    .agent
241                    .api
242                    .app
243                    .bsky
244                    .graph
245                    .get_list(
246                        api::app::bsky::graph::get_list::ParametersData {
247                            cursor: None,
248                            limit: Some(limit),
249                            list: args.uri.to_string(),
250                        }
251                        .into(),
252                    )
253                    .await?,
254            ),
255            Command::GetProfile(args) => self.print(
256                &self
257                    .agent
258                    .api
259                    .app
260                    .bsky
261                    .actor
262                    .get_profile(
263                        api::app::bsky::actor::get_profile::ParametersData {
264                            actor: args.actor.unwrap_or(self.handle().await?.into()),
265                        }
266                        .into(),
267                    )
268                    .await?,
269            ),
270            Command::GetPreferences => self.print(
271                &self
272                    .agent
273                    .api
274                    .app
275                    .bsky
276                    .actor
277                    .get_preferences(
278                        api::app::bsky::actor::get_preferences::ParametersData {}.into(),
279                    )
280                    .await?,
281            ),
282            Command::ListNotifications => self.print(
283                &self
284                    .agent
285                    .api
286                    .app
287                    .bsky
288                    .notification
289                    .list_notifications(
290                        api::app::bsky::notification::list_notifications::ParametersData {
291                            cursor: None,
292                            limit: Some(limit),
293                            priority: None,
294                            seen_at: None,
295                            reasons: None,
296                        }
297                        .into(),
298                    )
299                    .await?,
300            ),
301            Command::ListConvos => self.print(
302                &self
303                    .agent
304                    .api_with_proxy(
305                        BSKY_CHAT_DID.parse().expect("valid DID"),
306                        AtprotoServiceType::BskyChat,
307                    )
308                    .chat
309                    .bsky
310                    .convo
311                    .list_convos(
312                        api::chat::bsky::convo::list_convos::ParametersData {
313                            cursor: None,
314                            limit: Some(limit),
315                        }
316                        .into(),
317                    )
318                    .await?,
319            ),
320            Command::SendConvoMessage(args) => {
321                let did = match args.actor {
322                    AtIdentifier::Handle(handle) => {
323                        self.agent
324                            .api
325                            .com
326                            .atproto
327                            .identity
328                            .resolve_handle(
329                                api::com::atproto::identity::resolve_handle::ParametersData {
330                                    handle: handle.clone(),
331                                }
332                                .into(),
333                            )
334                            .await?
335                            .data
336                            .did
337                    }
338                    AtIdentifier::Did(did) => did,
339                };
340                let chat = &self
341                    .agent
342                    .api_with_proxy(
343                        BSKY_CHAT_DID.parse().expect("valid DID"),
344                        AtprotoServiceType::BskyChat,
345                    )
346                    .chat;
347                let convo = chat
348                    .bsky
349                    .convo
350                    .get_convo_for_members(
351                        api::chat::bsky::convo::get_convo_for_members::ParametersData {
352                            members: vec![did],
353                        }
354                        .into(),
355                    )
356                    .await?;
357                self.print(
358                    &chat
359                        .bsky
360                        .convo
361                        .send_message(
362                            api::chat::bsky::convo::send_message::InputData {
363                                convo_id: convo.data.convo.data.id,
364                                message: api::chat::bsky::convo::defs::MessageInputData {
365                                    embed: None,
366                                    facets: None,
367                                    text: args.text,
368                                }
369                                .into(),
370                            }
371                            .into(),
372                        )
373                        .await?,
374                )
375            }
376            Command::CreatePost(args) => {
377                let mut images = Vec::new();
378                for image in &args.images {
379                    if let Ok(mut file) = File::open(image).await {
380                        let mut buf = Vec::new();
381                        file.read_to_end(&mut buf).await.expect("read image file");
382                        let output = self
383                            .agent
384                            .api
385                            .com
386                            .atproto
387                            .repo
388                            .upload_blob(buf)
389                            .await
390                            .expect("upload blob");
391                        images.push(
392                            api::app::bsky::embed::images::ImageData {
393                                alt: image
394                                    .file_name()
395                                    .map(OsStr::to_string_lossy)
396                                    .unwrap_or_default()
397                                    .into(),
398                                aspect_ratio: None,
399                                image: output.data.blob,
400                            }
401                            .into(),
402                        )
403                    }
404                }
405                let embed = Some(api::types::Union::Refs(
406                    api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedImagesMain(Box::new(
407                        api::app::bsky::embed::images::MainData { images }.into(),
408                    )),
409                ));
410                self.print(
411                    &self
412                        .agent
413                        .create_record(api::app::bsky::feed::post::RecordData {
414                            created_at: Datetime::now(),
415                            embed,
416                            entities: None,
417                            facets: None,
418                            labels: None,
419                            langs: None,
420                            reply: None,
421                            tags: None,
422                            text: args.text,
423                        })
424                        .await?,
425                )
426            }
427            Command::DeletePost(args) => self.print(
428                &self
429                    .agent
430                    .api
431                    .com
432                    .atproto
433                    .repo
434                    .delete_record(
435                        api::com::atproto::repo::delete_record::InputData {
436                            collection: "app.bsky.feed.post".parse().expect("valid"),
437                            repo: self.handle().await?.into(),
438                            rkey: args.uri.rkey,
439                            swap_commit: None,
440                            swap_record: None,
441                        }
442                        .into(),
443                    )
444                    .await?,
445            ),
446        }
447    }
448    fn print<T: std::fmt::Debug + Serialize>(&self, result: &T) -> Result<()> {
449        if self.debug {
450            println!("{:#?}", result);
451        } else {
452            println!("{}", serde_json::to_string_pretty(result)?);
453        }
454        Ok(())
455    }
456    async fn handle(&self) -> Result<Handle> {
457        Ok(self.agent.get_session().await.with_context(|| "Not logged in")?.data.handle)
458    }
459}