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                        }
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                            read_state: None,
316                            status: None,
317                        }
318                        .into(),
319                    )
320                    .await?,
321            ),
322            Command::SendConvoMessage(args) => {
323                let did = match args.actor {
324                    AtIdentifier::Handle(handle) => {
325                        self.agent
326                            .api
327                            .com
328                            .atproto
329                            .identity
330                            .resolve_handle(
331                                api::com::atproto::identity::resolve_handle::ParametersData {
332                                    handle: handle.clone(),
333                                }
334                                .into(),
335                            )
336                            .await?
337                            .data
338                            .did
339                    }
340                    AtIdentifier::Did(did) => did,
341                };
342                let chat = &self
343                    .agent
344                    .api_with_proxy(
345                        BSKY_CHAT_DID.parse().expect("valid DID"),
346                        AtprotoServiceType::BskyChat,
347                    )
348                    .chat;
349                let convo = chat
350                    .bsky
351                    .convo
352                    .get_convo_for_members(
353                        api::chat::bsky::convo::get_convo_for_members::ParametersData {
354                            members: vec![did],
355                        }
356                        .into(),
357                    )
358                    .await?;
359                self.print(
360                    &chat
361                        .bsky
362                        .convo
363                        .send_message(
364                            api::chat::bsky::convo::send_message::InputData {
365                                convo_id: convo.data.convo.data.id,
366                                message: api::chat::bsky::convo::defs::MessageInputData {
367                                    embed: None,
368                                    facets: None,
369                                    text: args.text,
370                                }
371                                .into(),
372                            }
373                            .into(),
374                        )
375                        .await?,
376                )
377            }
378            Command::CreatePost(args) => {
379                let mut images = Vec::new();
380                for image in &args.images {
381                    if let Ok(mut file) = File::open(image).await {
382                        let mut buf = Vec::new();
383                        file.read_to_end(&mut buf).await.expect("read image file");
384                        let output = self
385                            .agent
386                            .api
387                            .com
388                            .atproto
389                            .repo
390                            .upload_blob(buf)
391                            .await
392                            .expect("upload blob");
393                        images.push(
394                            api::app::bsky::embed::images::ImageData {
395                                alt: image
396                                    .file_name()
397                                    .map(OsStr::to_string_lossy)
398                                    .unwrap_or_default()
399                                    .into(),
400                                aspect_ratio: None,
401                                image: output.data.blob,
402                            }
403                            .into(),
404                        )
405                    }
406                }
407                let embed = Some(api::types::Union::Refs(
408                    api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedImagesMain(Box::new(
409                        api::app::bsky::embed::images::MainData { images }.into(),
410                    )),
411                ));
412                self.print(
413                    &self
414                        .agent
415                        .create_record(api::app::bsky::feed::post::RecordData {
416                            created_at: Datetime::now(),
417                            embed,
418                            entities: None,
419                            facets: None,
420                            labels: None,
421                            langs: None,
422                            reply: None,
423                            tags: None,
424                            text: args.text,
425                        })
426                        .await?,
427                )
428            }
429            Command::DeletePost(args) => self.print(
430                &self
431                    .agent
432                    .api
433                    .com
434                    .atproto
435                    .repo
436                    .delete_record(
437                        api::com::atproto::repo::delete_record::InputData {
438                            collection: "app.bsky.feed.post".parse().expect("valid"),
439                            repo: self.handle().await?.into(),
440                            rkey: RecordKey::new(args.uri.rkey).map_err(|e| anyhow::anyhow!(e))?,
441                            swap_commit: None,
442                            swap_record: None,
443                        }
444                        .into(),
445                    )
446                    .await?,
447            ),
448        }
449    }
450    fn print<T: std::fmt::Debug + Serialize>(&self, result: &T) -> Result<()> {
451        if self.debug {
452            println!("{:#?}", result);
453        } else {
454            println!("{}", serde_json::to_string_pretty(result)?);
455        }
456        Ok(())
457    }
458    async fn handle(&self) -> Result<Handle> {
459        Ok(self.agent.get_session().await.with_context(|| "Not logged in")?.data.handle)
460    }
461}