diff --git a/.env.example b/.env.example index c7a72c6..b528a75 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ +PUBLISHER_BLUESKY_HANDLE="..." +PUBLISHER_BLUESKY_PASSWORD="..." +PUBLISHER_DID="..." CHAT_GPT_API_KEY="fake-chat-gpt-key" DATABASE_URL="postgres://postgres:password@localhost/nederlandskie" -HOSTNAME="..." \ No newline at end of file +FEED_GENERATOR_HOSTNAME="..." diff --git a/Cargo.lock b/Cargo.lock index 02ecdf3..3cb2f3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,6 +75,54 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f58811cfac344940f1a400b6e6231ce35171f614f26439e80f8c1465c5cc0c" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bf0a05bbb2a83e5eb6fa36bb6e87baa08193c35ff52bbf6b38d8af2890e46" + +[[package]] +name = "anstyle-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58f54d10c6dfa51283a066ceab3ec1ab78d13fae00aa49243a45e4571fb79dfd" +dependencies = [ + "anstyle", + "windows-sys", +] + [[package]] name = "anyhow" version = "1.0.75" @@ -430,6 +478,52 @@ dependencies = [ "unsigned-varint", ] +[[package]] +name = "clap" +version = "4.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d7b8d5ec32af0fadc644bf1fd509a688c2103b185644bb1e29d164e0703136" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5179bb514e4d7c2051749d8fcefa2ed6d06a9f4e6d69faf3805f5d80b8cf8d56" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0862016ff20d69b84ef8247369fabf5c008a7417002411897d40ee1f4532b873" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.35", +] + +[[package]] +name = "clap_lex" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd7cc57abe963c6d3b9d8be5b06ba7c8957a930305ca90304f24ef040aa6f961" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + [[package]] name = "compact_str" version = "0.7.1" @@ -2248,6 +2342,7 @@ dependencies = [ "chat-gpt-lib-rs", "chrono", "ciborium", + "clap", "dotenv", "env_logger", "futures", @@ -3284,6 +3379,12 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strum" version = "0.24.1" @@ -3658,6 +3759,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index 1eab673..0fb78b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "nederlandskie" version = "0.1.0" edition = "2021" +default-run = "nederlandskie" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -14,6 +15,7 @@ axum = "0.6.20" chat-gpt-lib-rs = "0.2.1" chrono = "0.4.31" ciborium = "0.2.1" +clap = { version = "4.4.4", features = ["derive"] } dotenv = "0.15.0" env_logger = "0.10.0" futures = "0.3.28" diff --git a/README.md b/README.md index 925b058..322e0a2 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,32 @@ Heavily WIP. Doesn't work yet at all, but does read the stream of posts as they - [ ] Publish the feed - [ ] Handle deleting of posts -## Initial setup +## Configuration -Copy `.env.example` into `.env` and set up the environment variables within: +1. Copy `.env.example` into `.env` and set up the environment variables within: -- `CHAT_GPT_API_KEY` for your ChatGPT key -- `DATABASE_URL` for PostgreSQL credentials -- `HOSTNAME` to the hostname of where you intend to host the feed + - `PUBLISHER_BLUESKY_HANDLE` to your Bluesky handle + - `PUBLISHER_BLUESKY_PASSWORD` to Bluesky app password that you created in settings + - `CHAT_GPT_API_KEY` for your ChatGPT key + - `DATABASE_URL` for PostgreSQL credentials + - `FEED_GENERATOR_HOSTNAME` to the hostname of where you intend to host the feed + +2. Determine your own DID and put it in `PUBLISHER_DID` env variable in `.env`: + + ``` + cargo run --bin who_am_i + ``` ## Running +### Populate and serve the feed + `cargo run` + +### Determine your own did for publishing + +`cargo run --bin who_am_i` + +### Publish the feed + +`cargo run --bin publish_feed -- --help` diff --git a/src/bin/publish_feed.rs b/src/bin/publish_feed.rs new file mode 100644 index 0000000..1d4bcad --- /dev/null +++ b/src/bin/publish_feed.rs @@ -0,0 +1,66 @@ +extern crate nederlandskie; + +use std::env; + +use anyhow::{Context, Result}; +use clap::Parser; +use dotenv::dotenv; + +use nederlandskie::services::Bluesky; + +#[derive(Parser, Debug)] +struct Args { + /// Short name of the feed. Must match one of the defined algos. + #[arg(long)] + name: String, + + /// Name that will be displayed in Bluesky interface + #[arg(long)] + display_name: String, + + /// Description that will be displayed in Bluesky interface + #[arg(long)] + description: String, + + /// Filename of the avatar that will be displayed + #[arg(long)] + avatar_filename: Option, +} + +#[tokio::main] +async fn main() -> Result<()> { + dotenv()?; + + let args = Args::parse(); + + let handle = env::var("PUBLISHER_BLUESKY_HANDLE") + .context("PUBLISHER_BLUESKY_HANDLE environment variable must be set")?; + + let password = env::var("PUBLISHER_BLUESKY_PASSWORD") + .context("PUBLISHER_BLUESKY_PASSWORD environment variable must be set")?; + + let feed_generator_did = format!("did:web:{}", env::var("FEED_GENERATOR_HOSTNAME")?); + + let bluesky = Bluesky::new("https://bsky.social"); + + let session = bluesky.login(&handle, &password).await?; + + let mut avatar = None; + if let Some(path) = args.avatar_filename { + let bytes = std::fs::read(path)?; + avatar = Some(bluesky.upload_blob(bytes).await?); + } + + bluesky + .publish_feed( + &session.did, + &feed_generator_did, + &args.name, + &args.display_name, + &args.description, + avatar, + ) + .await?; + + Ok(()) +} diff --git a/src/bin/who_am_i.rs b/src/bin/who_am_i.rs new file mode 100644 index 0000000..a4a6176 --- /dev/null +++ b/src/bin/who_am_i.rs @@ -0,0 +1,27 @@ +extern crate nederlandskie; + +use std::env; + +use anyhow::{Context, Result}; +use dotenv::dotenv; + +use nederlandskie::services::Bluesky; + +#[tokio::main] +async fn main() -> Result<()> { + dotenv()?; + + let bluesky = Bluesky::new("https://bsky.social"); + + let handle = env::var("PUBLISHER_BLUESKY_HANDLE") + .context("PUBLISHER_BLUESKY_HANDLE environment variable must be set")?; + + let password = env::var("PUBLISHER_BLUESKY_PASSWORD") + .context("PUBLISHER_BLUESKY_PASSWORD environment variable must be set")?; + + let session = bluesky.login(&handle, &password).await?; + + println!("{}", session.did); + + Ok(()) +} diff --git a/src/config.rs b/src/config.rs index 074b4df..634b993 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,9 +5,9 @@ use std::env; pub struct Config { pub chat_gpt_api_key: String, pub database_url: String, - pub service_did: String, + pub feed_generator_did: String, pub publisher_did: String, - pub hostname: String, + pub feed_generator_hostname: String, } impl Config { @@ -17,9 +17,9 @@ impl Config { Ok(Self { chat_gpt_api_key: env::var("CHAT_GPT_API_KEY")?, database_url: env::var("DATABASE_URL")?, - hostname: env::var("HOSTNAME")?, - service_did: format!("did:web:{}", env::var("HOSTNAME")?), - publisher_did: "".to_owned(), // TODO + feed_generator_hostname: env::var("FEED_GENERATOR_HOSTNAME")?, + feed_generator_did: format!("did:web:{}", env::var("FEED_GENERATOR_HOSTNAME")?), + publisher_did: env::var("PUBLISHER_DID")?, }) } } diff --git a/src/processes/feed_server/endpoints/describe_feed_generator.rs b/src/processes/feed_server/endpoints/describe_feed_generator.rs index a52c2e8..a00497a 100644 --- a/src/processes/feed_server/endpoints/describe_feed_generator.rs +++ b/src/processes/feed_server/endpoints/describe_feed_generator.rs @@ -9,7 +9,7 @@ pub async fn describe_feed_generator( State(state): State, ) -> Json { Json(FeedGeneratorDescription { - did: state.config.service_did.clone(), + did: state.config.feed_generator_did.clone(), feeds: state .algos .iter_names() diff --git a/src/processes/feed_server/endpoints/did_json.rs b/src/processes/feed_server/endpoints/did_json.rs index f8eb5fa..e14b3bd 100644 --- a/src/processes/feed_server/endpoints/did_json.rs +++ b/src/processes/feed_server/endpoints/did_json.rs @@ -22,11 +22,11 @@ pub struct Service { pub async fn did_json(State(state): State) -> Json { Json(Did { context: vec!["https://www.w3.org/ns/did/v1".to_owned()], - id: state.config.service_did.clone(), + id: state.config.feed_generator_did.clone(), service: vec![Service { id: "#bsky_fg".to_owned(), type_: "BskyFeedGenerator".to_owned(), - service_endpoint: format!("https://{}", state.config.hostname), + service_endpoint: format!("https://{}", state.config.feed_generator_hostname), }], }) } diff --git a/src/processes/post_indexer.rs b/src/processes/post_indexer.rs index 8d6d6f5..74eb9a8 100644 --- a/src/processes/post_indexer.rs +++ b/src/processes/post_indexer.rs @@ -38,12 +38,12 @@ impl PostIndexer { let cursor = self .database - .fetch_subscription_cursor(&self.config.service_did) + .fetch_subscription_cursor(&self.config.feed_generator_did) .await?; if cursor.is_none() { self.database - .create_subscription_state(&self.config.service_did) + .create_subscription_state(&self.config.feed_generator_did) .await?; } @@ -91,10 +91,10 @@ impl CommitProcessor for PostIndexer { if commit.seq % 20 == 0 { info!( "Updating cursor for {} to {}", - self.config.service_did, commit.seq + self.config.feed_generator_did, commit.seq ); self.database - .update_subscription_cursor(&self.config.service_did, commit.seq) + .update_subscription_cursor(&self.config.feed_generator_did, commit.seq) .await?; } diff --git a/src/services/bluesky/client.rs b/src/services/bluesky/client.rs index 8f865ff..5b03c89 100644 --- a/src/services/bluesky/client.rs +++ b/src/services/bluesky/client.rs @@ -1,7 +1,10 @@ use anyhow::{anyhow, Result}; +use atrium_api::blob::BlobRef; use atrium_api::client::AtpServiceClient; use atrium_api::client::AtpServiceWrapper; +use atrium_api::records::Record; use atrium_xrpc::client::reqwest::ReqwestClient; +use chrono::Utc; use futures::StreamExt; use log::error; use tokio_tungstenite::{connect_async, tungstenite}; @@ -14,6 +17,11 @@ pub struct ProfileDetails { pub description: String, } +#[derive(Debug)] +pub struct SessionDetails { + pub did: String, +} + pub struct Bluesky { client: AtpServiceClient>, } @@ -25,6 +33,77 @@ impl Bluesky { } } + pub async fn login(&self, handle: &str, password: &str) -> Result { + use atrium_api::com::atproto::server::create_session::Input; + + let result = self + .client + .service + .com + .atproto + .server + .create_session(Input { + identifier: handle.to_owned(), + password: password.to_owned(), + }) + .await?; + + Ok(SessionDetails { did: result.did }) + } + + pub async fn upload_blob(&self, blob: Vec) -> Result { + let result = self + .client + .service + .com + .atproto + .repo + .upload_blob(blob) + .await?; + + Ok(result.blob) + } + + pub async fn publish_feed( + &self, + publisher_did: &str, + feed_generator_did: &str, + name: &str, + display_name: &str, + description: &str, + avatar: Option, + ) -> Result<()> { + use atrium_api::com::atproto::repo::put_record::Input; + + self.client + .service + .com + .atproto + .repo + .put_record(Input { + collection: "app.bsky.feed.generator".to_owned(), + record: Record::AppBskyFeedGenerator(Box::new( + atrium_api::app::bsky::feed::generator::Record { + avatar, + created_at: Utc::now().to_string(), + description: Some(description.to_owned()), + description_facets: None, + did: feed_generator_did.to_owned(), + display_name: display_name.to_owned(), + labels: None, + }, + )), + repo: publisher_did.to_owned(), + rkey: name.to_owned(), + swap_commit: None, + swap_record: None, + validate: None, + }) + .await?; + + Ok(()) + } + pub async fn fetch_profile_details(&self, did: &str) -> Result { let result = self .client @@ -41,7 +120,7 @@ impl Bluesky { .await?; let profile = match result.value { - atrium_api::records::Record::AppBskyActorProfile(profile) => profile, + Record::AppBskyActorProfile(profile) => profile, _ => return Err(anyhow!("Big bad, no such profile")), };