Source code for my personal quote bot project.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

actor: primitive Bluesky Sink implementation.

This is working somewhat well, but we definitely
need to clean up the entrypoint and make each
sink adequately configurable.

+150 -20
+1
Cargo.lock
··· 142 142 version = "0.1.0" 143 143 dependencies = [ 144 144 "bsky-sdk", 145 + "chrono", 145 146 "cron-lite", 146 147 "futures", 147 148 "glob",
+1 -2
Cargo.toml
··· 1 - cargo-features = ["edition2024"] # For rust-analyzer to work 2 - 3 1 [package] 4 2 name = "audquotes" 5 3 version = "0.1.0" ··· 8 6 9 7 [dependencies] 10 8 bsky-sdk = "0.1.16" 9 + chrono = "0.4.42" 11 10 cron-lite = { version = "0.3.0", features = ["async"] } 12 11 futures = "0.3.31" 13 12 glob = "0.3.2"
+65 -10
src/lib.rs
··· 3 3 pub mod storage; 4 4 5 5 pub mod run { 6 - use crate::sink::{PostQuote, SinkManager, StdoutSink}; 6 + use std::time::Duration; 7 + 8 + use crate::sink::{BskySink, PostQuote, SinkManager, StdoutSink}; 7 9 use crate::storage::{ 8 10 FetchQuote, QuoteCycle, queue::MemoryQueueStorage, source::FsFilterSourceManager, 9 11 }; 12 + use cron_lite::CronEvent; 13 + use futures::StreamExt; 10 14 use kameo::prelude::*; 15 + use tokio::time::timeout; 11 16 12 17 pub async fn entrypoint() -> Result<(), Box<dyn std::error::Error>> { 13 18 // TODO: Clean up this function's internals. 14 19 // The current structure is alright, but it was stitched together 15 20 // quickly just to confirm that everything is functioning as it should. 21 + let use_bsky = std::env::var("USE_BLUESKY").unwrap_or("0".to_string()) == "1"; 22 + let bsky = if use_bsky { 23 + Some(BskySink::spawn( 24 + BskySink::new_session( 25 + std::env::var("BLUESKY_USERNAME").expect("Bluesky username not supplied"), 26 + std::env::var("BLUESKY_PASSWORD") 27 + .expect("Bluesky application password not supplied"), 28 + ) 29 + .await 30 + .expect("Could not connect to Bluesky with supplied credentials"), 31 + )) 32 + } else { 33 + None 34 + }; 16 35 17 36 let sink = { 18 37 let stdout = StdoutSink::spawn(StdoutSink); 19 - SinkManager::spawn(SinkManager::new(Some(stdout))) 38 + SinkManager::spawn(SinkManager::new(Some(stdout), bsky)) 20 39 }; 21 40 22 41 let cycle = { ··· 26 45 QuoteCycle::spawn(QuoteCycle::with_thread_rng(source, queue)) 27 46 }; 28 47 29 - loop { 30 - let next_quote = cycle 31 - .ask(FetchQuote) 32 - .await 33 - .map_err(|_| "fetch quote should always succeed")?; 34 - sink.tell(PostQuote(next_quote)).await?; 35 - tokio::time::sleep(std::time::Duration::from_secs(3)).await; 36 - println!() 48 + use cron_lite::Schedule; 49 + const POSTING_TIMEOUT: Duration = Duration::from_secs(60); 50 + const POSTING_INTERVAL: &str = "*/10 * * * * * *"; 51 + let schedule = 52 + Schedule::new(POSTING_INTERVAL).expect("Schedule should be a valid cron expression"); 53 + let now = chrono::Utc::now(); 54 + 55 + let mut tick_stream = schedule.stream(&now); 56 + 57 + while let Some(tick) = tick_stream.next().await { 58 + if let CronEvent::Missed(missed_at) = tick { 59 + eprintln!( 60 + "Missed event tick at {}. Current time: {}. Skipping post.", 61 + missed_at, 62 + chrono::Utc::now() 63 + ); 64 + continue; 65 + } 66 + 67 + // We store the code to perform the next posting iteration as one atomic future which we wrap with a timeout. 68 + // This means that, if we miss a posting window due to the timeout, we will not get multiple consecutive or late posts. 69 + let next_post_iteration = async || -> Result<(), Box<dyn std::error::Error>> { 70 + let next_quote = cycle 71 + .ask(FetchQuote) 72 + .await 73 + .map_err(|_| "fetch quote should always succeed")?; 74 + 75 + // Note: By using `tell`, we don't know when each sink's code will have completed. 76 + // If any sink uses, say, a file or stdout, that resource may well be contested between 77 + // consecutive iterations of this loop. 78 + sink.tell(PostQuote(next_quote)).await?; 79 + println!(); 80 + 81 + Ok(()) 82 + }; 83 + 84 + if let Err(e) = timeout(POSTING_TIMEOUT, next_post_iteration()).await { 85 + eprintln!( 86 + "Could not submit post in time to all sinks. Timeout error: {}", 87 + e 88 + ); 89 + } 37 90 } 91 + 92 + Ok(()) 38 93 } 39 94 }
+83 -8
src/sink.rs
··· 1 1 use crate::data::Quote; 2 + use bsky_sdk::{BskyAgent, api::types::Object}; 2 3 use kameo::prelude::*; 3 4 4 5 /// A newtype over [Quote] used to prompt the [SinkManager] to ··· 52 53 } 53 54 } 54 55 56 + /// A [QuoteSink] which will post the contents of each quote to Bluesky. 57 + #[derive(Actor)] 58 + pub struct BskySink { 59 + bsky_agent: BskyAgent, 60 + bsky_session: Object<bsky_sdk::api::com::atproto::server::create_session::OutputData>, 61 + } 62 + 63 + impl BskySink { 64 + pub async fn new_session(username: String, password: String) -> Result<Self, ()> { 65 + let agent = BskyAgent::builder().build().await.map_err(|_| ())?; 66 + let session = agent.login(username, password).await.map_err(|_| ())?; 67 + 68 + Ok(Self { 69 + bsky_agent: agent, 70 + bsky_session: session, 71 + }) 72 + } 73 + 74 + async fn submit_post(&mut self, quote: Quote) -> Result<(), ()> { 75 + let post = bsky_sdk::api::app::bsky::feed::post::RecordData { 76 + text: quote.into(), 77 + created_at: bsky_sdk::api::types::string::Datetime::now(), 78 + embed: None, 79 + entities: None, 80 + facets: None, 81 + labels: None, 82 + langs: None, 83 + reply: None, 84 + tags: None, 85 + }; 86 + 87 + if let Err(e) = self 88 + .bsky_agent 89 + .resume_session(self.bsky_session.clone()) 90 + .await 91 + { 92 + eprintln!("Failed to resume sessions due to following error: {e}"); 93 + return Err(()); 94 + } 95 + 96 + match self.bsky_agent.create_record(post.clone()).await { 97 + Ok(_) => Ok(()), 98 + Err(_) => Err(()), 99 + } 100 + } 101 + } 102 + 103 + impl Message<PostQuote> for BskySink { 104 + type Reply = PostResult; 105 + 106 + async fn handle( 107 + &mut self, 108 + PostQuote(quote): PostQuote, 109 + _ctx: &mut Context<Self, Self::Reply>, 110 + ) -> Self::Reply { 111 + match self.submit_post(quote).await { 112 + Ok(_) => Ok(()), 113 + Err(_) => Err(PostFailure::Unrecoverable), 114 + } 115 + } 116 + } 117 + 55 118 /// Supervises all [QuoteSink] actors within the program, forwarding 56 119 /// [PostQuote] messages to them as they are received. 57 120 /// The SinkManager will attempt to reinitialize failed sinks upon ··· 63 126 // do asynchronous dynamic dispatch for it here. 64 127 // I've decided I'll limit this to one sink per implementation right now. 65 128 stdout_sink: Option<ActorRef<StdoutSink>>, 129 + bsky_sink: Option<ActorRef<BskySink>>, 66 130 // ... 67 131 } 68 132 69 133 impl SinkManager { 70 - pub fn new(stdout_sink: Option<ActorRef<StdoutSink>>) -> Self { 71 - Self { stdout_sink } 134 + pub fn new( 135 + stdout_sink: Option<ActorRef<StdoutSink>>, 136 + bsky_sink: Option<ActorRef<BskySink>>, 137 + ) -> Self { 138 + Self { 139 + stdout_sink, 140 + bsky_sink, 141 + } 72 142 } 73 143 } 74 144 ··· 84 154 ) -> Self::Reply { 85 155 use futures::future::join_all; 86 156 87 - // We'll see if this monstrosity actually works 88 - let sinks = [self.stdout_sink.clone()]; 89 - let futures = sinks 90 - .iter() 91 - .flatten() 157 + let stdout_result = self 158 + .stdout_sink 159 + .as_ref() 92 160 .map(|s| s.ask(msg.clone()).into_future()); 161 + 162 + let bsky_result = self 163 + .bsky_sink 164 + .as_ref() 165 + .map(|s| s.ask(msg.clone()).into_future()); 166 + 167 + let futures = [stdout_result, bsky_result].into_iter().flatten(); 93 168 let results = join_all(futures).await; 94 169 95 170 results.iter().map(|r| r.clone().or(Err(()))).collect() ··· 102 177 use super::*; 103 178 104 179 let stdout = StdoutSink::spawn(StdoutSink); 105 - let manager = SinkManager::spawn(SinkManager::new(Some(stdout))); 180 + let manager = SinkManager::spawn(SinkManager::new(Some(stdout), None)); 106 181 107 182 let messages = ["First test!", "Second test.", "Third..."]; 108 183 for msg in messages {