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 }
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}