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 let preferences = self.agent.get_preferences(true).await?;
58 self.agent.configure_labelers_from_preferences(&preferences);
59 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}